update local and mpv plugins, add file cmdlet, update docs

This commit is contained in:
2026-05-14 17:15:13 -07:00
parent 9f0eb29289
commit 036977832b
10 changed files with 653 additions and 297 deletions
+172 -14
View File
@@ -731,8 +731,6 @@ class CmdletCompleter(Completer):
return canonical_cmd
lowered = {str(tok or "").strip().lower() for tok in (stage_tokens or [])}
if "-search" in lowered or "--search" in lowered:
return "search-file"
if "-download" in lowered or "--download" in lowered or "-dl" in lowered or "--dl" in lowered:
return "download-file"
if "-add" in lowered or "--add" in lowered:
@@ -743,6 +741,10 @@ class CmdletCompleter(Completer):
return "delete-file"
if "-merge" in lowered or "--merge" in lowered:
return "merge-file"
if "-plugin" in lowered or "--plugin" in lowered or any(tok.startswith("-plugin=") or tok.startswith("--plugin=") for tok in lowered):
return "search-file"
if "-query" in lowered or "--query" in lowered or any(tok.startswith("-query=") or tok.startswith("--query=") for tok in lowered):
return "search-file"
return canonical_cmd
@staticmethod
@@ -750,7 +752,12 @@ class CmdletCompleter(Completer):
canonical_cmd = CmdletCompleter._effective_cmd_name(cmd_name, stage_tokens)
if canonical_cmd not in {"search-file", "add-file", "download-file"}:
return None
return CmdletCompleter._flag_value(stage_tokens, "-plugin", "--plugin")
raw_plugin = CmdletCompleter._flag_value(stage_tokens, "-plugin", "--plugin")
if raw_plugin:
# Strip quotes if present, then normalize to lowercase
stripped = CmdletCompleter._strip_quotes(str(raw_plugin or ""))
return stripped.strip().lower() if stripped else None
return None
@staticmethod
def _plugin_instance_choices(plugin_name: Optional[str], config: Dict[str, Any]) -> List[str]:
@@ -788,6 +795,84 @@ class CmdletCompleter(Completer):
out.append(text)
return out
@staticmethod
def _plugin_instance_accepts_direct_path(plugin_name: Optional[str]) -> bool:
return str(plugin_name or "").strip().lower() == "local"
@staticmethod
def _looks_like_path_fragment(value: str) -> bool:
text = str(value or "").strip()
if not text:
return False
if text[:1] in {"'", '"'}:
text = text[1:]
if not text:
return False
if text.startswith((".", "~", "\\", "/")):
return True
if "\\" in text or "/" in text:
return True
if len(text) >= 2 and text[1] == ":":
return True
return False
@staticmethod
def _path_instance_choices(current_token: str) -> List[str]:
raw = str(current_token or "")
if not CmdletCompleter._looks_like_path_fragment(raw):
return []
quote_prefix = raw[:1] if raw[:1] in {"'", '"'} else ""
fragment = raw[1:] if quote_prefix else raw
if not fragment:
return []
expanded = os.path.expanduser(fragment)
candidate = Path(expanded)
if fragment.endswith(("\\", "/")):
parent = candidate
prefix = ""
else:
parent = candidate.parent if str(candidate.parent) not in {"", "."} else Path.cwd()
prefix = candidate.name
try:
if not parent.exists() or not parent.is_dir():
return []
except Exception:
return []
out: List[str] = []
seen: Set[str] = set()
prefix_lower = prefix.lower()
try:
entries = sorted(parent.iterdir(), key=lambda item: item.name.lower())
except Exception:
return []
for entry in entries:
try:
if not entry.is_dir():
continue
if prefix_lower and not entry.name.lower().startswith(prefix_lower):
continue
suggestion = str(entry)
if quote_prefix:
suggestion = quote_prefix + suggestion
elif " " in suggestion:
suggestion = f'"{suggestion}"'
except Exception:
continue
lowered = suggestion.lower()
if lowered in seen:
continue
seen.add(lowered)
out.append(suggestion)
return out
def _filter_stage_arg_names(
self,
*,
@@ -803,12 +888,15 @@ class CmdletCompleter(Completer):
plugin_name = self._selected_plugin_name(canonical_cmd, stage_tokens)
instance_choices = self._plugin_instance_choices(plugin_name, config)
has_named_instances = bool(instance_choices)
accepts_direct_path = self._plugin_instance_accepts_direct_path(plugin_name)
filtered: List[str] = []
for arg in arg_names:
logical = str(arg or "").lstrip("-").strip().lower()
if logical == "instance":
if not plugin_name or not has_named_instances:
if not plugin_name:
continue
if not has_named_instances and not accepts_direct_path:
continue
if canonical_cmd == "search-file" and logical == "open":
if str(plugin_name or "").strip().lower() != "alldebrid":
@@ -816,6 +904,59 @@ class CmdletCompleter(Completer):
filtered.append(arg)
return filtered
@staticmethod
def _tokenize_quoted(text: str) -> List[str]:
"""Tokenize text preserving quoted strings as single tokens.
Handles pipes as pipeline separators and preserves quoted strings
(single or double quotes) as atomic tokens.
"""
tokens = []
current = ""
in_quote = None # None, "'", or '"'
i = 0
while i < len(text):
char = text[i]
if in_quote:
current += char
if char == in_quote and (i == 0 or text[i - 1] != "\\"):
in_quote = None
elif char in ("'", '"'):
in_quote = char
current += char
elif char == "|":
if current.strip():
tokens.append(current.strip())
tokens.append("|")
current = ""
elif char.isspace():
if current.strip():
tokens.append(current.strip())
current = ""
else:
current += char
i += 1
if current.strip():
tokens.append(current.strip())
return tokens
@staticmethod
def _strip_quotes(token: str) -> str:
"""Remove surrounding quotes from a token if present.
Preserves internal content exactly. Only removes matching outer quotes.
"""
token = str(token or "").strip()
if len(token) >= 2:
if (token[0] == '"' and token[-1] == '"') or (token[0] == "'" and token[-1] == "'"):
return token[1:-1]
return token
def get_completions(
self,
document: Document,
@@ -824,7 +965,7 @@ class CmdletCompleter(Completer):
self._refresh_cmdlet_names()
text = document.text_before_cursor
tokens = text.split()
tokens = self._tokenize_quoted(text)
ends_with_space = bool(text) and text[-1].isspace()
last_pipe = -1
@@ -885,17 +1026,22 @@ class CmdletCompleter(Completer):
cmd_name = stage_tokens[0].replace("_", "-").lower()
effective_cmd = self._effective_cmd_name(cmd_name, stage_tokens)
if ends_with_space:
raw_current_token = ""
current_token = ""
prev_token = stage_tokens[-1].lower()
else:
current_token = stage_tokens[-1].lower()
raw_current_token = stage_tokens[-1]
current_token = raw_current_token.lower()
prev_token = stage_tokens[-2].lower() if len(stage_tokens) > 1 else ""
config = self._config_loader.load_shared()
provider_name = None
if effective_cmd == "search-file":
provider_name = self._flag_value(stage_tokens, "-plugin", "--plugin")
raw_provider = self._flag_value(stage_tokens, "-plugin", "--plugin")
if raw_provider:
stripped = self._strip_quotes(str(raw_provider or ""))
provider_name = stripped.strip().lower() if stripped else None
selected_plugin = self._selected_plugin_name(effective_cmd, stage_tokens)
@@ -912,8 +1058,10 @@ class CmdletCompleter(Completer):
query_fragment: Optional[str] = None
if prev_token in {"-query", "--query"} and current_token[:1] in {"'", '"'}:
query_fragment = current_token
elif query_started_quoted and not ends_with_space:
query_fragment = current_token
elif query_started_quoted and not ends_with_space and not current_token.startswith("-"):
# Only continue in query mode if the previous token is not a flag (new argument)
if not prev_token.startswith("-"):
query_fragment = current_token
elif query_started_quoted and ends_with_space and ":" in prev_token:
query_fragment = ""
@@ -1010,6 +1158,16 @@ class CmdletCompleter(Completer):
choices: List[str] = []
if normalized_prev == "instance" and selected_plugin:
choices = self._plugin_instance_choices(selected_plugin, config)
if self._plugin_instance_accepts_direct_path(selected_plugin):
path_choices = self._path_instance_choices(raw_current_token)
if path_choices:
seen_choice_values = {str(choice).lower() for choice in choices}
for choice in path_choices:
lowered = str(choice).lower()
if lowered in seen_choice_values:
continue
choices.append(choice)
seen_choice_values.add(lowered)
if not choices:
choices = self._arg_choices(
cmd_name=effective_cmd,
@@ -1032,7 +1190,7 @@ class CmdletCompleter(Completer):
choice_list = filtered
for choice in choice_list:
yield Completion(choice, start_position=-len(current_token))
yield Completion(choice, start_position=-len(raw_current_token))
# Example: if the user has typed `download-file -url ...`, then `url`
# is considered used and should not be suggested again (even as `--url`).
return
@@ -1253,8 +1411,6 @@ class CmdletExecutor:
def _file_action(args: Optional[List[str]]) -> str | None:
tokens = [str(t or "").strip().lower() for t in (args or [])]
token_set = set(tokens)
if "-search" in token_set or "--search" in token_set:
return "search-file"
if "-download" in token_set or "--download" in token_set or "-dl" in token_set or "--dl" in token_set:
return "download-file"
if "-get" in token_set or "--get" in token_set:
@@ -1265,6 +1421,8 @@ class CmdletExecutor:
return "delete-file"
if "-merge" in token_set or "--merge" in token_set:
return "merge-file"
if "-query" in token_set or "--query" in token_set or any(tok.startswith("-query=") or tok.startswith("--query=") for tok in token_set):
return "search-file"
return None
normalized_cmd = str(cmd_name or "").replace("_", "-").lower().strip()
@@ -1761,8 +1919,6 @@ class CmdletExecutor:
if norm != "file":
return norm
lowered = {str(a or "").strip().lower() for a in (args or [])}
if "-search" in lowered or "--search" in lowered:
return "search-file"
if "-download" in lowered or "--download" in lowered or "-dl" in lowered or "--dl" in lowered:
return "download-file"
if "-get" in lowered or "--get" in lowered:
@@ -1771,6 +1927,8 @@ class CmdletExecutor:
return "add-file"
if "-delete" in lowered or "--delete" in lowered or "-del" in lowered or "--del" in lowered:
return "delete-file"
if "-query" in lowered or "--query" in lowered or any(a.startswith("-query=") or a.startswith("--query=") for a in lowered):
return "search-file"
return norm
effective_cmd = _effective_cmd_name(cmd_name, filtered_args)
+62 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import sys
from importlib import import_module, reload as reload_module
from pathlib import Path
from types import ModuleType
from typing import Any, Dict, List, Optional
import logging
@@ -71,17 +72,76 @@ def _normalize_mod_name(mod_name: str) -> str:
return normalized
def _nested_cmdlet_modules(normalized: str) -> List[str]:
"""Return nested cmdlet module candidates for names like search_file."""
if not normalized:
return []
try:
cmdlet_dir = Path(__file__).resolve().parent.parent / "cmdlet"
except Exception:
return []
if not cmdlet_dir.is_dir():
return []
candidates: List[str] = []
seen: set[str] = set()
parts = normalized.split("_", 1)
try:
children = sorted(cmdlet_dir.iterdir(), key=lambda path: path.name.lower())
except Exception:
return []
for child in children:
if not child.is_dir() or not (child / "__init__.py").is_file():
continue
direct_file = child / f"{normalized}.py"
if direct_file.is_file():
module_name = f"cmdlet.{child.name}.{normalized}"
if module_name not in seen:
seen.add(module_name)
candidates.append(module_name)
if len(parts) != 2:
continue
left, right = parts
if child.name == right and (child / f"{left}.py").is_file():
module_name = f"cmdlet.{right}.{left}"
if module_name not in seen:
seen.add(module_name)
candidates.append(module_name)
if child.name == left and (child / f"{right}.py").is_file():
module_name = f"cmdlet.{left}.{right}"
if module_name not in seen:
seen.add(module_name)
candidates.append(module_name)
return candidates
def import_cmd_module(mod_name: str, *, reload_loaded: bool = False):
"""Import a cmdlet/command module from legacy or plugin-owned packages."""
normalized = _normalize_mod_name(mod_name)
if not normalized:
return None
for qualified in (
qualified_names = [
f"plugins.{normalized}.commands",
f"cmdnat.{normalized}",
f"cmdlet.{normalized}",
*_nested_cmdlet_modules(normalized),
normalized,
):
]
seen: set[str] = set()
for qualified in qualified_names:
if qualified in seen:
continue
seen.add(qualified)
try:
# When attempting a bare import (package is None), prefer the repo-local
# `MPV` package for the `mpv` module name so we don't accidentally
+1 -1
View File
@@ -2397,7 +2397,7 @@ class PipelineHubApp(App):
query = f"hash:{hash_value}"
base_copy = (
f"file -search -instance {json.dumps(store_name)} {json.dumps(query)}"
f"file -instance {json.dumps(store_name)} -query {json.dumps(query)}"
f" | file -add -instance {json.dumps(selected_store)}"
)
if action == "move_to_selected_store":
+2 -2
View File
@@ -43,8 +43,8 @@ PIPELINE_PRESETS: List[PipelinePreset] = [
PipelinePreset(
label="Search Local Library",
description=
"Run file -search against the local library and emit a result table for further piping.",
pipeline='file -search -library local -query "<keywords>"',
"Run file -query against the local library and emit a result table for further piping.",
pipeline='file -library local -query "<keywords>"',
),
]
+187 -268
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
from typing import Any, Dict, Optional, Sequence, Tuple, List
from pathlib import Path
from copy import deepcopy
import sys
import shutil
import tempfile
@@ -11,7 +10,7 @@ from urllib.parse import urlparse
from SYS import models
from SYS import pipeline as ctx
from SYS.logger import log, debug, debug_panel, is_debug_enabled
from SYS.logger import log, debug, debug_panel
from SYS.payload_builders import build_table_result_payload
from SYS.pipeline_progress import PipelineProgress
from SYS.result_publication import overlay_existing_result_table, publish_result_table
@@ -92,41 +91,11 @@ class _CommandDependencies:
self._plugins[cache_key] = plugin
return plugin
DEBUG_PIPE_NOTE_PREVIEW_LENGTH = 256
# Protocol schemes that identify a remote resource / not a local file path.
# Used by multiple methods in this file to guard against URL strings being
# treated as local file paths.
_REMOTE_URL_PREFIXES: tuple[str, ...] = (
"http://", "https://", "ftp://", "ftps://", "magnet:", "torrent:", "tidal:", "hydrus:",
)
def _truncate_debug_note_text(value: Any) -> str:
raw = str(value or "")
if len(raw) <= DEBUG_PIPE_NOTE_PREVIEW_LENGTH:
return raw
return raw[:DEBUG_PIPE_NOTE_PREVIEW_LENGTH].rstrip() + "..."
def _sanitize_pipe_object_for_debug(pipe_obj: models.PipeObject) -> models.PipeObject:
safe_po = deepcopy(pipe_obj)
try:
extra = safe_po.extra
if isinstance(extra, dict):
sanitized = dict(extra)
notes = sanitized.get("notes")
if isinstance(notes, dict):
truncated_notes: Dict[str, str] = {}
for note_name, note_value in notes.items():
truncated_notes[str(note_name)] = _truncate_debug_note_text(note_value)
sanitized["notes"] = truncated_notes
safe_po.extra = sanitized
except Exception:
pass
return safe_po
def _maybe_apply_florencevision_tags(
media_path: Path,
tags: List[str],
@@ -224,9 +193,9 @@ class Add_File(Cmdlet):
super().__init__(
name="add-file",
summary=
"Ingest a local media file to a configured instance, upload plugin, or local directory.",
"Ingest a local media file to a configured store or plugin destination.",
usage=
"add-file (-path <filepath> | <piped>) (-instance <name|path> | -plugin <upload-plugin>) [-delete]",
"add-file (-path <filepath> | <piped>) (-instance <store-name> | -plugin <plugin> [-instance <name|path>]) [-delete]",
arg=[
SharedArgs.PATH,
SharedArgs.INSTANCE,
@@ -242,11 +211,10 @@ class Add_File(Cmdlet):
],
detail=[
"Note: add-file ingests local files. To fetch remote sources, use download-file and pipe into add-file.",
"- Instance/location options (use -instance):",
"- Store options (use -instance without -plugin):",
" hydrus: Upload to Hydrus database with metadata tagging",
" local: Copy file to local directory",
" <path>: Copy file to specified directory",
"- Upload plugin options (use -plugin):",
"- Plugin options (use -plugin):",
" local: Copy file to a configured local destination or direct path via -instance",
" 0x0: Upload to 0x0.st for temporary hosting",
" file.io: Upload to file.io for temporary hosting",
" internetarchive: Upload to archive.org (optional tag: ia:<identifier> to upload into an existing item)",
@@ -254,6 +222,7 @@ class Add_File(Cmdlet):
],
examples=[
'download-file "https://themathesontrust.org/papers/christianity/alcock-alphabet1.pdf" | add-file -instance tutorial',
'@1 | add-file -plugin local -instance C:\\Users\\Me\\Downloads',
'add-file -plugin ftp -instance archive -path C:\\Media\\report.pdf',
],
exec=self.run,
@@ -275,25 +244,19 @@ class Add_File(Cmdlet):
source_url_arg = parsed.get("url")
plugin_name = parsed.get("plugin")
delete_after = parsed.get("delete", False)
local_export_destination: Optional[str] = None
if plugin_name and not plugin_instance and location:
plugin_instance = location
# Convenience: when piping a file into add-file, allow `-path <existing dir>`
# to act as the destination export directory.
# Example: screen-shot "https://..." | add-file -path "C:\Users\Admin\Desktop"
# Backward-compatible shorthand: when piping a file into add-file, allow
# `-path <existing dir>` to normalize into the local export plugin path.
if path_arg and not location and not plugin_name:
try:
candidate_dir = Path(str(path_arg))
if candidate_dir.exists() and candidate_dir.is_dir():
debug_panel(
"add-file destination",
[
("mode", "local export"),
("path", candidate_dir),
],
border_style="cyan",
)
location = str(candidate_dir)
plugin_name = "local"
plugin_instance = str(candidate_dir)
local_export_destination = str(candidate_dir)
path_arg = None
except Exception:
pass
@@ -397,13 +360,44 @@ class Add_File(Cmdlet):
is_storage_backend_location = False
if location and not plugin_name and not is_storage_backend_location:
if not Add_File._looks_like_local_export_target(str(location)):
resolved_local_instance, resolved_local_path = Add_File._resolve_local_export_plugin_target(
location,
config,
deps=deps,
require_explicit=True,
)
if resolved_local_path:
plugin_name = "local"
plugin_instance = resolved_local_instance or str(location)
location = None
local_export_destination = resolved_local_path
else:
log(
f"Storage backend '{location}' not found. Use -path for local export or configure that store backend.",
f"Storage backend '{location}' not found. Use -plugin local -instance <name|path> for local export or configure that store backend.",
file=sys.stderr,
)
return 1
normalized_plugin_name = Add_File._normalize_provider_key(plugin_name)
if normalized_plugin_name == "local":
resolved_local_instance, resolved_local_path = Add_File._resolve_local_export_plugin_target(
plugin_instance or location,
config,
deps=deps,
require_explicit=bool(plugin_instance or location),
)
if not resolved_local_path:
requested_local = str(plugin_instance or location or "").strip() or "<default>"
log(
f"Local destination '{requested_local}' is not configured. Use -plugin local -instance <name|path>.",
file=sys.stderr,
)
return 1
plugin_name = "local"
plugin_instance = resolved_local_instance or str(plugin_instance or location or "").strip() or None
location = None
local_export_destination = resolved_local_path
plugin_storage_backend = None
if plugin_name:
plugin_storage_backend = Add_File._resolve_plugin_storage_backend(
@@ -469,46 +463,8 @@ class Add_File(Cmdlet):
except Exception:
use_steps = False
try:
debug_panel(
"add-file",
[
("result_type", type(result).__name__),
("items", total_items),
("location", location),
("plugin", plugin_name),
("instance", plugin_instance),
("delete", delete_after),
],
border_style="cyan",
)
except Exception:
pass
# add-file is ingestion-only: it does not download URLs here.
# Show a concise PipeObject preview when debug logging is enabled to aid pipeline troubleshooting.
if is_debug_enabled():
preview_items = (
items_to_process if isinstance(items_to_process, list)
else [items_to_process]
)
max_preview = 5
for idx, item in enumerate(preview_items[:max_preview]):
po = item if isinstance(item, models.PipeObject) else None
if po is None:
try:
po = coerce_to_pipe_object(item, path_arg)
except Exception:
po = None
if po is None:
continue
try:
safe_po = _sanitize_pipe_object_for_debug(po)
safe_po.debug_table()
except Exception:
pass
should_present_directory_selector = bool(dir_scan_mode and not has_downstream_stage)
if dir_scan_mode and has_downstream_stage:
debug(
@@ -666,12 +622,19 @@ class Add_File(Cmdlet):
if use_steps and steps_started:
progress.step("resolving source")
export_destination = (
Path(local_export_destination)
if local_export_destination
else Path(location)
if location and not is_storage_backend_location
else None
)
media_path, file_hash, temp_dir_to_cleanup = self._resolve_source(
item,
path_arg,
pipe_obj,
config,
export_destination=(Path(location) if location and not is_storage_backend_location else None),
export_destination=export_destination,
store_instance=storage_registry,
deps=deps,
)
@@ -679,19 +642,6 @@ class Add_File(Cmdlet):
media_path, file_hash, temp_dir_to_cleanup = Add_File._download_piped_source(
pipe_obj, config, storage_registry, deps=deps
)
if media_path:
try:
debug_panel(
f"add-file source {idx}/{max(1, total_items)}",
[
("path", media_path),
("hash", file_hash or "N/A"),
("plugin", plugin_name or "local"),
],
border_style="green",
)
except Exception:
pass
if not media_path:
failures += 1
continue
@@ -768,13 +718,8 @@ class Add_File(Cmdlet):
store_instance=storage_registry,
)
else:
code = self._handle_local_export(
media_path,
location,
pipe_obj,
config,
delete_after_item
)
log(f"Invalid storage backend: {location}", file=sys.stderr)
code = 1
except Exception as exc:
debug(f"[add-file] ERROR: Failed to resolve location: {exc}")
log(f"Invalid location: {location}", file=sys.stderr)
@@ -1371,27 +1316,6 @@ class Add_File(Cmdlet):
pass
return None
@staticmethod
def _looks_like_local_export_target(location: str) -> bool:
target = str(location or "").strip()
if not target:
return False
target_path = Path(target).expanduser()
try:
if target_path.exists():
return True
except Exception:
pass
if target.startswith((".", "~")):
return True
if "\\" in target or "/" in target:
return True
if len(target) >= 2 and target[1] == ":":
return True
return False
@staticmethod
def _resolve_source(
result: Any,
@@ -1608,6 +1532,45 @@ class Add_File(Cmdlet):
return resolved_text
@staticmethod
def _resolve_local_export_plugin_target(
requested: Optional[Any],
config: Dict[str, Any],
*,
deps: Optional[_CommandDependencies] = None,
require_explicit: bool = False,
) -> tuple[Optional[str], Optional[str]]:
if deps is None:
deps = _CommandDependencies(config)
file_provider = deps.get_plugin_with_capability("local", "upload")
if file_provider is None:
return None, None
resolver = getattr(file_provider, "resolve_destination", None)
if not callable(resolver):
return None, None
requested_text = str(requested or "").strip() or None
try:
resolved_name, settings = resolver(
requested_text,
require_explicit=require_explicit,
)
except TypeError:
try:
resolved_name, settings = resolver(requested_text)
except Exception:
return None, None
except Exception:
return None, None
path_value = str((settings or {}).get("path") or "").strip()
if not path_value:
return None, None
resolved_text = str(resolved_name or requested_text or "").strip() or None
return resolved_text, path_value
@staticmethod
def _maybe_download_plugin_result(
result: Any,
@@ -2294,136 +2257,72 @@ class Add_File(Cmdlet):
return None
@staticmethod
def _handle_local_export(
media_path: Path,
location: str,
def _emit_plugin_upload_payload(
upload_payload: Dict[str, Any],
plugin_name: str,
instance_name: Optional[str],
pipe_obj: models.PipeObject,
config: Dict[str,
Any],
media_path: Path,
delete_after: bool,
) -> int:
"""Handle exporting to a specific local path (Copy)."""
try:
destination_root = Path(location)
except Exception as exc:
log(f"❌ Invalid destination path '{location}': {exc}", file=sys.stderr)
return 1
payload = dict(upload_payload or {})
extra_updates: Dict[str, Any] = {}
raw_extra = payload.get("extra")
if isinstance(raw_extra, dict):
extra_updates.update(raw_extra)
direct_export_download = False
try:
if isinstance(pipe_obj.extra, dict):
direct_export_download = bool(pipe_obj.extra.pop("_direct_export_download", False))
except Exception:
direct_export_download = False
if plugin_name:
extra_updates.setdefault("plugin", plugin_name)
if instance_name:
extra_updates.setdefault("instance", instance_name)
try:
debug_panel(
"add-file export",
[
("destination", destination_root),
("source", media_path),
],
border_style="green",
)
except Exception:
pass
raw_urls = payload.get("url")
if isinstance(raw_urls, str):
url_values = [raw_urls.strip()] if raw_urls.strip() else []
extra_updates["url"] = url_values
elif isinstance(raw_urls, (list, tuple, set)):
url_values = [str(item).strip() for item in raw_urls if str(item).strip()]
extra_updates["url"] = url_values
result = None
tags, url, title, f_hash = Add_File._prepare_metadata(result, media_path, pipe_obj, config)
# Determine Filename (Title-based)
title_value = title
if not title_value:
# Try to find title in tags
title_tag = next(
(t for t in tags if str(t).strip().lower().startswith("title:")),
None
)
if title_tag:
title_value = title_tag.split(":", 1)[1].strip()
if not title_value:
title_value = media_path.stem.replace("_", " ").strip()
safe_title = "".join(
c for c in title_value if c.isalnum() or c in " ._-()[]{}'`"
).strip()
base_name = safe_title or media_path.stem
# Fix to prevent double extensions (e.g., file.exe.exe)
# If the base name already ends with the extension of the media file,
# don't append it again.
file_ext = media_path.suffix
if file_ext and base_name.lower().endswith(file_ext.lower()):
new_name = base_name
else:
new_name = base_name + file_ext
destination_root.mkdir(parents=True, exist_ok=True)
target_path = destination_root / new_name
if direct_export_download:
target_path = media_path
else:
if target_path.exists():
target_path = unique_path(target_path)
# COPY Operation (Safe Export)
try:
shutil.copy2(str(media_path), target_path)
except Exception as exc:
log(f"❌ Failed to export file: {exc}", file=sys.stderr)
return 1
# Copy Sidecars
Add_File._copy_sidecars(media_path, target_path)
# Ensure hash for exported copy
if not f_hash:
try:
f_hash = sha256_file(target_path)
except Exception:
f_hash = None
# Write Metadata Sidecars (since it's an export)
relationships = Add_File._get_relationships(result, pipe_obj)
try:
write_sidecar(target_path, tags, url, f_hash)
from SYS.metadata import write_metadata # lazy: avoids 1000+ module chain at startup
write_metadata(
target_path,
hash_value=f_hash,
url=url,
relationships=relationships or []
)
except Exception:
pass
# Update PipeObject and emit
extra_updates = {
"url": url,
"export_path": str(destination_root),
}
relationships = payload.get("relationships")
if relationships:
extra_updates["relationships"] = relationships
try:
pipe_obj.relationships = relationships
except Exception:
pass
chosen_title = title or title_value or pipe_obj.title or target_path.name
tags = payload.get("tag")
if isinstance(tags, list):
tag_values = [str(tag) for tag in tags]
else:
tag_values = list(pipe_obj.tag or [])
title_value = str(payload.get("title") or pipe_obj.title or media_path.name).strip() or media_path.name
path_value = str(payload.get("path") or pipe_obj.path or media_path).strip()
hash_value = str(
payload.get("hash")
or payload.get("file_hash")
or getattr(pipe_obj, "hash", None)
or "unknown"
).strip() or "unknown"
store_value = str(payload.get("store") or "").strip()
provider_value = payload.get("provider")
if provider_value is None and plugin_name:
provider_value = plugin_name
Add_File._update_pipe_object_destination(
pipe_obj,
hash_value=f_hash or "unknown",
store="local",
path=str(target_path),
tag=tags,
title=chosen_title,
hash_value=hash_value,
store=store_value,
provider=str(provider_value) if provider_value else None,
path=path_value,
tag=tag_values,
title=title_value,
extra_updates=extra_updates,
)
Add_File._emit_pipe_object(pipe_obj)
# Cleanup
# Only delete if explicitly requested!
Add_File._cleanup_after_success(media_path, delete_source=delete_after)
return 0
@staticmethod
@@ -2459,10 +2358,37 @@ class Add_File(Cmdlet):
show_available_plugins_panel(sorted(available_uploads))
return 1
hoster_url = file_provider.upload(
upload_kwargs: Dict[str, Any] = {
"pipe_obj": pipe_obj,
"instance": instance_name,
}
normalized_plugin_name = Add_File._normalize_provider_key(plugin_name)
f_hash = Add_File._resolve_file_hash(None, media_path, pipe_obj, None)
if normalized_plugin_name == "local":
result = None
tags, urls, title, f_hash = Add_File._prepare_metadata(result, media_path, pipe_obj, config)
relationships = Add_File._get_relationships(result, pipe_obj)
direct_export_download = False
try:
if isinstance(pipe_obj.extra, dict):
direct_export_download = bool(pipe_obj.extra.pop("_direct_export_download", False))
except Exception:
direct_export_download = False
upload_kwargs.update(
{
"title": title,
"tags": tags,
"urls": urls,
"hash_value": f_hash,
"relationships": relationships,
"direct_export_download": direct_export_download,
}
)
upload_result = file_provider.upload(
str(media_path),
pipe_obj=pipe_obj,
instance=instance_name,
**upload_kwargs,
)
duplicate_upload = False
@@ -2478,29 +2404,22 @@ class Add_File(Cmdlet):
duplicate_rule = ""
duplicate_target = ""
try:
debug_panel(
"add-file plugin upload",
[
("plugin", plugin_name),
("instance", instance_name or "<default>"),
("source", media_path),
("duplicate", duplicate_upload),
("rule", duplicate_rule or "none"),
("target", duplicate_target or ""),
("url", hoster_url),
],
border_style="yellow" if duplicate_upload else "green",
)
except Exception:
pass
f_hash = Add_File._resolve_file_hash(None, media_path, pipe_obj, None)
except Exception as exc:
log(f"Upload failed: {exc}", file=sys.stderr)
return 1
if isinstance(upload_result, dict):
return Add_File._emit_plugin_upload_payload(
upload_result,
plugin_name,
instance_name,
pipe_obj,
media_path,
delete_after,
)
hoster_url = str(upload_result or "").strip()
# Update PipeObject and emit
extra_updates: Dict[str,
Any] = {
+20 -6
View File
@@ -21,7 +21,6 @@ class File(Cmdlet):
"get": {"-get", "--get"},
"merge": {"-merge", "--merge"},
"download": {"-download", "--download", "-dl", "--dl"},
"search": {"-search", "--search"},
"convert": {"-convert", "--convert"},
"trim": {"-trim", "--trim"},
"archive": {"-archive", "--archive"},
@@ -45,9 +44,10 @@ class File(Cmdlet):
super().__init__(
name="file",
summary="Manage file operations with one command",
usage='file (-add|-delete|-get|-merge|-download|-search|-convert|-trim|-archive|-screenshot) [args]',
usage='file -query <query> [args] | file (-add|-delete|-get|-merge|-download|-convert|-trim|-archive|-screenshot) [args]',
arg=[
SharedArgs.QUERY,
SharedArgs.PLUGIN,
SharedArgs.INSTANCE,
SharedArgs.PATH,
CmdletArg("-add", type="flag", required=False, description="Run add-file"),
@@ -55,21 +55,32 @@ class File(Cmdlet):
CmdletArg("-get", type="flag", required=False, description="Run get-file"),
CmdletArg("-merge", type="flag", required=False, description="Run merge-file"),
CmdletArg("-download", type="flag", required=False, description="Run download-file", alias="dl"),
CmdletArg("-search", type="flag", required=False, description="Run search-file"),
CmdletArg("-convert", type="flag", required=False, description="Run convert-file"),
CmdletArg("-trim", type="flag", required=False, description="Run trim-file"),
CmdletArg("-archive", type="flag", required=False, description="Run archive-file"),
CmdletArg("-screenshot", type="flag", required=False, description="Run screen-shot", alias="shot"),
],
detail=[
"- Exactly one action flag is required.",
"- Use -query to run search-file through the unified file command.",
"- Otherwise, exactly one non-search action flag is required.",
"- Remaining args are passed through to the selected file cmdlet.",
"- Examples: file -add ..., file -delete ..., file -merge ...",
"- Examples: file -query ..., file -add ..., file -delete ...",
],
exec=self.run,
)
self.register()
@staticmethod
def _has_query_arg(args: Sequence[str]) -> bool:
query_flags = {"-query", "--query"}
for token in args or []:
text = str(token or "").strip().lower()
if text in query_flags:
return True
if any(text.startswith(f"{flag}=") for flag in query_flags):
return True
return False
@classmethod
def _extract_action(cls, args: Sequence[str]) -> tuple[str | None, List[str], List[str]]:
matched_actions: List[str] = []
@@ -93,6 +104,9 @@ class File(Cmdlet):
if action not in unique_actions:
unique_actions.append(action)
if not unique_actions and cls._has_query_arg(passthrough):
return "search", passthrough, unique_actions
if len(unique_actions) != 1:
return None, passthrough, unique_actions
return unique_actions[0], passthrough, unique_actions
@@ -125,7 +139,7 @@ class File(Cmdlet):
if action is None:
if not seen:
log(
"file: missing action flag; choose exactly one of -add, -delete, -get, -merge, -download, -search, -convert, -trim, -archive, -screenshot",
"file: missing action; use -query for search or choose exactly one of -add, -delete, -get, -merge, -download, -convert, -trim, -archive, -screenshot",
file=sys.stderr,
)
else:
+2 -2
View File
@@ -143,13 +143,13 @@ Selection & download flows
- `download-file` integration: With a file row (http(s) path), `@2 | download-file` will download the file. The `download-file` cmdlet expands AllDebrid magnet folders and will call the provider layer to fetch file bytes as appropriate.
- `add-file` convenience: Piping a file row into `add-file -path <dest>` will trigger add-file's provider-aware logic. If the piped item has `table == 'alldebrid'` and a http(s) `path`, `add-file` will call `provider.download()` into a temporary directory and then ingest the downloaded file, cleaning up the temp when done. Example:
- `add-file` convenience: Piping a file row into `add-file -plugin local -instance <dest>` will trigger add-file's provider-aware logic. If the piped item has `table == 'alldebrid'` and a http(s) `path`, `add-file` will call `provider.download()` into a temporary directory and then ingest the downloaded file, cleaning up the temp when done. Example:
```
# Expand magnet and add first file to local directory
search-file -plugin alldebrid "*"
@3 # view files
@1 | add-file -path C:\mydir
@1 | add-file -plugin local -instance C:\mydir
```
Notes & troubleshooting
+205
View File
@@ -0,0 +1,205 @@
from __future__ import annotations
import shutil
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from ProviderCore.base import Provider
from SYS.metadata import write_metadata, write_tags
from SYS.utils import sanitize_filename, sha256_file, unique_path
def _copy_sidecars(source_path: Path, target_path: Path) -> None:
possible_sidecars = [
source_path.with_suffix(source_path.suffix + ".json"),
source_path.with_name(source_path.name + ".tag"),
source_path.with_name(source_path.name + ".metadata"),
source_path.with_name(source_path.name + ".notes"),
]
for sidecar in possible_sidecars:
try:
if not sidecar.exists():
continue
suffix_part = sidecar.name.replace(source_path.name, "", 1)
target_sidecar = target_path.parent / f"{target_path.name}{suffix_part}"
target_sidecar.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(sidecar), target_sidecar)
except Exception:
continue
class Local(Provider):
PLUGIN_NAME = "local"
PLUGIN_ALIASES = ("filesystem", "fs")
MULTI_INSTANCE = True
SUPPORTED_CMDLETS = frozenset({"add-file"})
@property
def label(self) -> str:
return "Local Filesystem"
@classmethod
def config_schema(cls) -> List[Dict[str, Any]]:
return [
{
"key": "path",
"label": "Destination Path",
"type": "path",
"default": "",
"required": True,
"placeholder": r"C:\Users\Me\Downloads",
},
{
"key": "create_dirs",
"label": "Create Missing Directories",
"type": "boolean",
"default": True,
},
]
def config_helper_text(self) -> str:
return "Configure named local export destinations and use add-file -plugin local -instance <name|path>."
@staticmethod
def _looks_like_path(value: Any) -> bool:
text = str(value or "").strip()
if not text:
return False
if text.startswith((".", "~")):
return True
if "\\" in text or "/" in text:
return True
if len(text) >= 2 and text[1] == ":":
return True
return False
def _settings_from_config(
self,
conf: Optional[Dict[str, Any]],
*,
instance_name: Optional[str] = None,
) -> Dict[str, Any]:
entry = dict(conf or {})
path_value = str(entry.get("path") or entry.get("PATH") or "").strip()
return {
"instance": str(instance_name or entry.get("_instance_name") or "").strip() or None,
"path": path_value,
"create_dirs": bool(entry.get("create_dirs", entry.get("createDirs", True))),
}
def resolve_destination(
self,
instance_name: Optional[str] = None,
*,
require_explicit: bool = False,
) -> Tuple[Optional[str], Dict[str, Any]]:
requested = str(instance_name or "").strip()
if requested:
resolved_name, conf = self.resolve_plugin_instance(requested, require_explicit=True)
settings = self._settings_from_config(conf, instance_name=resolved_name)
if settings.get("path"):
return resolved_name or requested, settings
if self._looks_like_path(requested):
return requested, {
"instance": requested,
"path": requested,
"create_dirs": True,
}
if require_explicit:
return None, {}
resolved_name, conf = self.resolve_plugin_instance(None, require_explicit=False)
settings = self._settings_from_config(conf, instance_name=resolved_name)
if settings.get("path"):
return resolved_name, settings
return None, {}
def validate(self) -> bool:
return True
def upload(self, file_path: str, **kwargs: Any) -> Dict[str, Any]:
source_path = Path(str(file_path or "")).expanduser()
if not source_path.exists() or not source_path.is_file():
raise FileNotFoundError(f"File not found: {source_path}")
requested_instance = str(kwargs.get("instance") or kwargs.get("store") or "").strip() or None
resolved_name, settings = self.resolve_destination(
requested_instance,
require_explicit=bool(requested_instance),
)
destination_text = str(settings.get("path") or "").strip()
if not destination_text:
requested_label = requested_instance or "<default>"
raise ValueError(
f"Local destination '{requested_label}' is not configured. Use -plugin local -instance <name|path>."
)
destination_root = Path(destination_text).expanduser()
create_dirs = bool(settings.get("create_dirs", True))
if create_dirs:
destination_root.mkdir(parents=True, exist_ok=True)
elif not destination_root.exists():
raise FileNotFoundError(f"Destination directory does not exist: {destination_root}")
elif not destination_root.is_dir():
raise NotADirectoryError(f"Destination is not a directory: {destination_root}")
title = str(kwargs.get("title") or "").strip()
if not title:
title = source_path.stem.replace("_", " ").strip()
base_name = sanitize_filename(title or source_path.stem)
file_ext = source_path.suffix
if file_ext and base_name.lower().endswith(file_ext.lower()):
target_name = base_name
else:
target_name = base_name + file_ext
direct_export_download = bool(kwargs.get("direct_export_download", False))
target_path = source_path if direct_export_download else destination_root / target_name
if not direct_export_download:
if target_path.exists():
target_path = unique_path(target_path)
shutil.copy2(str(source_path), target_path)
_copy_sidecars(source_path, target_path)
tags = list(kwargs.get("tags") or [])
urls = list(kwargs.get("urls") or [])
hash_value = str(kwargs.get("hash_value") or "").strip() or None
if not hash_value:
try:
hash_value = sha256_file(target_path)
except Exception:
hash_value = None
relationships = kwargs.get("relationships")
try:
write_tags(target_path, tags, urls, hash_value=hash_value)
write_metadata(
target_path,
hash_value=hash_value,
url=urls,
relationships=relationships or [],
)
except Exception:
pass
extra_updates: Dict[str, Any] = {
"url": urls,
"export_path": str(destination_root),
}
if resolved_name:
extra_updates["instance"] = resolved_name
if relationships:
extra_updates["relationships"] = relationships
return {
"hash": hash_value or "unknown",
"store": "local",
"provider": self.name,
"path": str(target_path),
"tag": tags,
"title": title or target_path.name,
"relationships": relationships,
"extra": extra_updates,
}
+1 -1
View File
@@ -5903,7 +5903,7 @@ mp.register_script_message('medios-download-pick-path', function()
local pipeline_cmd = 'file -download -url ' .. quote_pipeline_arg(url)
.. ' -query ' .. quote_pipeline_arg(query)
.. ' | file -add -path ' .. quote_pipeline_arg(folder)
.. ' | file -add -plugin local -instance ' .. quote_pipeline_arg(folder)
_queue_pipeline_in_repl(
pipeline_cmd,
+1 -1
View File
@@ -474,7 +474,7 @@ class MPV:
if store:
pipeline += f" | file -add -instance {_q(store)}"
else:
pipeline += f" | file -add -path {_q(path or '')}"
pipeline += f" | file -add -plugin local -instance {_q(path or '')}"
try:
from TUI.pipeline_runner import PipelineRunner # noqa: WPS433