update local and mpv plugins, add file cmdlet, update docs
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user