From 036977832b9f85228f3a97a4f02a5fb1b15a55b5 Mon Sep 17 00:00:00 2001 From: Nose Date: Thu, 14 May 2026 17:15:13 -0700 Subject: [PATCH] update local and mpv plugins, add file cmdlet, update docs --- CLI.py | 186 ++++++++++++++-- SYS/cmdlet_catalog.py | 64 +++++- TUI.py | 2 +- TUI/menu_actions.py | 4 +- cmdlet/file/add.py | 455 ++++++++++++++++---------------------- cmdlet/file_cmdlet.py | 26 ++- docs/result_table.md | 4 +- plugins/local/__init__.py | 205 +++++++++++++++++ plugins/mpv/LUA/main.lua | 2 +- plugins/mpv/mpv_ipc.py | 2 +- 10 files changed, 653 insertions(+), 297 deletions(-) create mode 100644 plugins/local/__init__.py diff --git a/CLI.py b/CLI.py index c474111..5bf521e 100644 --- a/CLI.py +++ b/CLI.py @@ -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) diff --git a/SYS/cmdlet_catalog.py b/SYS/cmdlet_catalog.py index 2135a61..65a16bf 100644 --- a/SYS/cmdlet_catalog.py +++ b/SYS/cmdlet_catalog.py @@ -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 diff --git a/TUI.py b/TUI.py index 61c7493..6e47af9 100644 --- a/TUI.py +++ b/TUI.py @@ -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": diff --git a/TUI/menu_actions.py b/TUI/menu_actions.py index 855c879..4fe6f51 100644 --- a/TUI/menu_actions.py +++ b/TUI/menu_actions.py @@ -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 ""', + "Run file -query against the local library and emit a result table for further piping.", + pipeline='file -library local -query ""', ), ] diff --git a/cmdlet/file/add.py b/cmdlet/file/add.py index ba5c013..06649a4 100644 --- a/cmdlet/file/add.py +++ b/cmdlet/file/add.py @@ -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 | ) (-instance | -plugin ) [-delete]", + "add-file (-path | ) (-instance | -plugin [-instance ]) [-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", - " : 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: 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 ` - # 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 ` 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 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 "" + log( + f"Local destination '{requested_local}' is not configured. Use -plugin local -instance .", + 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 ""), - ("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] = { diff --git a/cmdlet/file_cmdlet.py b/cmdlet/file_cmdlet.py index 502cfed..884721e 100644 --- a/cmdlet/file_cmdlet.py +++ b/cmdlet/file_cmdlet.py @@ -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 [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: diff --git a/docs/result_table.md b/docs/result_table.md index 91fa0cd..45b93e1 100644 --- a/docs/result_table.md +++ b/docs/result_table.md @@ -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 ` 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 ` 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 diff --git a/plugins/local/__init__.py b/plugins/local/__init__.py new file mode 100644 index 0000000..10d5db5 --- /dev/null +++ b/plugins/local/__init__.py @@ -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 ." + + @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 "" + raise ValueError( + f"Local destination '{requested_label}' is not configured. Use -plugin local -instance ." + ) + + 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, + } \ No newline at end of file diff --git a/plugins/mpv/LUA/main.lua b/plugins/mpv/LUA/main.lua index bf99f47..50832ea 100644 --- a/plugins/mpv/LUA/main.lua +++ b/plugins/mpv/LUA/main.lua @@ -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, diff --git a/plugins/mpv/mpv_ipc.py b/plugins/mpv/mpv_ipc.py index d7548da..a4a4787 100644 --- a/plugins/mpv/mpv_ipc.py +++ b/plugins/mpv/mpv_ipc.py @@ -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