huge refactor of plugin system
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -131,7 +131,7 @@ def _send_mpv_ipc_command(
|
||||
return False
|
||||
|
||||
try:
|
||||
from MPV.mpv_ipc import MPVIPCClient, get_ipc_pipe_path
|
||||
from plugins.mpv.mpv_ipc import MPVIPCClient, get_ipc_pipe_path
|
||||
|
||||
client = MPVIPCClient(
|
||||
socket_path=str(ipc_path or get_ipc_pipe_path()),
|
||||
@@ -2179,7 +2179,7 @@ Come to love it when others take what you share, as there is no greater joy
|
||||
|
||||
try:
|
||||
try:
|
||||
from MPV.mpv_ipc import MPV
|
||||
from plugins.mpv.mpv_ipc import MPV
|
||||
import shutil
|
||||
|
||||
MPV()
|
||||
@@ -2272,7 +2272,7 @@ Come to love it when others take what you share, as there is no greater joy
|
||||
if _has_store_subtype(config, "debrid"):
|
||||
try:
|
||||
from SYS.config import get_debrid_api_key
|
||||
from API.alldebrid import AllDebridClient
|
||||
from plugins.alldebrid.api import AllDebridClient
|
||||
|
||||
api_key = get_debrid_api_key(config)
|
||||
if not api_key:
|
||||
|
||||
+2
-4
@@ -1,5 +1,3 @@
|
||||
from MPV.mpv_ipc import MPV
|
||||
from plugins.mpv import MPV
|
||||
|
||||
__all__ = [
|
||||
"MPV",
|
||||
]
|
||||
__all__ = ["MPV"]
|
||||
+3
-45
@@ -1,48 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if not args:
|
||||
payload: Dict[str, Any] = {
|
||||
"success": False,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"error": "Missing url",
|
||||
"table": None,
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
url = str(args[0] or "").strip()
|
||||
captured_stdout = io.StringIO()
|
||||
captured_stderr = io.StringIO()
|
||||
with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr):
|
||||
from MPV.pipeline_helper import _run_op
|
||||
|
||||
payload = _run_op("ytdlp-formats", {"url": url})
|
||||
|
||||
noisy_stdout = captured_stdout.getvalue().strip()
|
||||
noisy_stderr = captured_stderr.getvalue().strip()
|
||||
if noisy_stdout:
|
||||
payload["stdout"] = "\n".join(filter(None, [str(payload.get("stdout") or "").strip(), noisy_stdout]))
|
||||
if noisy_stderr:
|
||||
payload["stderr"] = "\n".join(filter(None, [str(payload.get("stderr") or "").strip(), noisy_stderr]))
|
||||
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
return 0 if payload.get("success") else 1
|
||||
from plugins.mpv.format_probe import *
|
||||
from plugins.mpv.format_probe import main as _main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
raise SystemExit(_main())
|
||||
+3
-2020
File diff suppressed because it is too large
Load Diff
+1
-1172
File diff suppressed because it is too large
Load Diff
+3
-2157
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
"""Legacy compatibility package.
|
||||
|
||||
Bundled runtime plugins now live under ``plugins/``. This package remains only
|
||||
for helper modules and backwards-compatible imports that have not been removed
|
||||
yet.
|
||||
"""
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Legacy compatibility shim for the strict adapter example module.
|
||||
|
||||
The active implementation now lives in ``plugins.example_provider`` so the
|
||||
plugin namespace owns the example adapter module. Keep this file only to avoid
|
||||
breaking old imports while the legacy ``Provider`` package is phased out.
|
||||
"""
|
||||
|
||||
from plugins.example_provider import * # noqa: F401,F403
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Legacy compatibility shim for metadata helpers.
|
||||
|
||||
The active implementation now lives in ``plugins.metadata_provider`` so the
|
||||
plugin namespace owns runtime metadata scraping. Keep this file only to avoid
|
||||
breaking old imports while the legacy ``Provider`` package is phased out.
|
||||
"""
|
||||
|
||||
from plugins.metadata_provider import * # noqa: F401,F403
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Legacy compatibility shim for Tidal manifest helpers.
|
||||
|
||||
The active implementation now lives in ``plugins.tidal_manifest`` so the
|
||||
plugin namespace owns the manifest helper module. Keep this file only to avoid
|
||||
breaking old imports while the legacy ``Provider`` package is phased out.
|
||||
"""
|
||||
|
||||
from plugins.tidal_manifest import * # noqa: F401,F403
|
||||
+46
-7
@@ -30,6 +30,7 @@ class SearchResult:
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for pipeline processing."""
|
||||
full_metadata = self.full_metadata if isinstance(self.full_metadata, dict) else {}
|
||||
out = {
|
||||
"table": self.table,
|
||||
"title": self.title,
|
||||
@@ -40,15 +41,29 @@ class SearchResult:
|
||||
"size_bytes": self.size_bytes,
|
||||
"tag": list(self.tag),
|
||||
"columns": list(self.columns),
|
||||
"full_metadata": self.full_metadata,
|
||||
"full_metadata": full_metadata,
|
||||
}
|
||||
|
||||
try:
|
||||
url_value = getattr(self, "url", None)
|
||||
if url_value is not None:
|
||||
out["url"] = url_value
|
||||
except Exception:
|
||||
pass
|
||||
for key in (
|
||||
"url",
|
||||
"hash",
|
||||
"hash_hex",
|
||||
"store",
|
||||
"name",
|
||||
"mime",
|
||||
"file_id",
|
||||
"ext",
|
||||
"size",
|
||||
):
|
||||
value = None
|
||||
try:
|
||||
value = getattr(self, key, None)
|
||||
except Exception:
|
||||
value = None
|
||||
if value is None and key in full_metadata:
|
||||
value = full_metadata.get(key)
|
||||
if value is not None:
|
||||
out[key] = value
|
||||
|
||||
try:
|
||||
selection_args = getattr(self, "selection_args", None)
|
||||
@@ -195,6 +210,30 @@ class Provider(ABC):
|
||||
"""
|
||||
return "search-file", list(args_list)
|
||||
|
||||
def resolve_pipe_item_context(
|
||||
self,
|
||||
item: Any,
|
||||
*,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
store: Optional[str] = None,
|
||||
file_hash: Optional[str] = None,
|
||||
targets: Optional[Sequence[str]] = None,
|
||||
) -> Optional[Tuple[Optional[str], Optional[str]]]:
|
||||
"""Optionally normalize store/hash context for pipe playback helpers."""
|
||||
_ = item, metadata, store, file_hash, targets
|
||||
return None
|
||||
|
||||
def infer_playlist_store(
|
||||
self,
|
||||
item: Any,
|
||||
*,
|
||||
target: str,
|
||||
file_storage: Any = None,
|
||||
) -> Optional[str]:
|
||||
"""Optionally infer a friendly store label for an MPV playlist entry."""
|
||||
_ = item, target, file_storage
|
||||
return None
|
||||
|
||||
@property
|
||||
def prefers_transfer_progress(self) -> bool:
|
||||
"""True if this plugin prefers explicit transfer progress tracking (begin/finish) during download."""
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterable, Sequence
|
||||
|
||||
|
||||
CmdletFn = Callable[[Any, Sequence[str], Dict[str, Any]], int]
|
||||
|
||||
|
||||
def iter_command_objects(module: Any) -> list[Any]:
|
||||
objects: list[Any] = []
|
||||
|
||||
many = getattr(module, "COMMANDS", None)
|
||||
if isinstance(many, (list, tuple)):
|
||||
for item in many:
|
||||
if item is not None:
|
||||
objects.append(item)
|
||||
|
||||
single = getattr(module, "COMMAND", None)
|
||||
if single is not None:
|
||||
objects.append(single)
|
||||
|
||||
legacy = getattr(module, "CMDLET", None)
|
||||
if legacy is not None:
|
||||
objects.append(legacy)
|
||||
|
||||
deduped: list[Any] = []
|
||||
seen: set[int] = set()
|
||||
for item in objects:
|
||||
marker = id(item)
|
||||
if marker in seen:
|
||||
continue
|
||||
seen.add(marker)
|
||||
deduped.append(item)
|
||||
return deduped
|
||||
|
||||
|
||||
def get_primary_command_object(module: Any) -> Any:
|
||||
commands = iter_command_objects(module)
|
||||
return commands[0] if commands else None
|
||||
|
||||
|
||||
def _register_command_object(cmdlet_obj: Any, registry: Dict[str, CmdletFn]) -> None:
|
||||
run_fn = getattr(cmdlet_obj, "exec", None) if hasattr(cmdlet_obj, "exec") else None
|
||||
if not callable(run_fn):
|
||||
return
|
||||
|
||||
name = getattr(cmdlet_obj, "name", None)
|
||||
if name:
|
||||
registry[str(name).replace("_", "-").lower()] = run_fn
|
||||
|
||||
aliases: list[str] = []
|
||||
if hasattr(cmdlet_obj, "alias") and getattr(cmdlet_obj, "alias"):
|
||||
aliases.extend(getattr(cmdlet_obj, "alias") or [])
|
||||
if hasattr(cmdlet_obj, "aliases") and getattr(cmdlet_obj, "aliases"):
|
||||
aliases.extend(getattr(cmdlet_obj, "aliases") or [])
|
||||
|
||||
for alias in aliases:
|
||||
text = str(alias or "").strip()
|
||||
if text:
|
||||
registry[text.replace("_", "-").lower()] = run_fn
|
||||
|
||||
|
||||
def iter_plugin_command_module_names() -> list[str]:
|
||||
try:
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
plugins_dir = repo_root / "plugins"
|
||||
if not plugins_dir.is_dir():
|
||||
return []
|
||||
|
||||
module_names: list[str] = []
|
||||
for entry in sorted(plugins_dir.iterdir(), key=lambda path: path.name.lower()):
|
||||
if not entry.is_dir() or entry.name.startswith("."):
|
||||
continue
|
||||
if not (entry / "__init__.py").is_file():
|
||||
continue
|
||||
if (entry / "commands.py").is_file() or (entry / "commands" / "__init__.py").is_file():
|
||||
module_names.append(f"plugins.{entry.name}.commands")
|
||||
return module_names
|
||||
|
||||
|
||||
def register_plugin_commands(registry: Dict[str, CmdletFn]) -> None:
|
||||
for module_name in iter_plugin_command_module_names():
|
||||
try:
|
||||
module = import_module(module_name)
|
||||
for cmdlet_obj in iter_command_objects(module):
|
||||
_register_command_object(cmdlet_obj, registry)
|
||||
except Exception as exc:
|
||||
import sys
|
||||
|
||||
print(
|
||||
f"Error importing plugin command '{module_name}': {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
@@ -505,6 +505,18 @@ def _supports_capability(provider: Provider, capability: str) -> bool:
|
||||
return _supports_search(provider)
|
||||
if capability_key in {"upload", "file", "file-provider"}:
|
||||
return _supports_upload(provider)
|
||||
if capability_key in {"pipe-item-context", "pipe-context"}:
|
||||
return _class_supports_method(
|
||||
provider.__class__,
|
||||
"resolve_pipe_item_context",
|
||||
Provider.resolve_pipe_item_context,
|
||||
)
|
||||
if capability_key in {"playlist-store", "playback-store"}:
|
||||
return _class_supports_method(
|
||||
provider.__class__,
|
||||
"infer_playlist_store",
|
||||
Provider.infer_playlist_store,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
@@ -514,6 +526,18 @@ def _info_supports_capability(info: PluginInfo, capability: str) -> bool:
|
||||
return bool(info.supports_search)
|
||||
if capability_key in {"upload", "file", "file-provider"}:
|
||||
return bool(info.supports_upload)
|
||||
if capability_key in {"pipe-item-context", "pipe-context"}:
|
||||
return _class_supports_method(
|
||||
info.plugin_class,
|
||||
"resolve_pipe_item_context",
|
||||
Provider.resolve_pipe_item_context,
|
||||
)
|
||||
if capability_key in {"playlist-store", "playback-store"}:
|
||||
return _class_supports_method(
|
||||
info.plugin_class,
|
||||
"infer_playlist_store",
|
||||
Provider.infer_playlist_store,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
+12
-7
@@ -5,6 +5,7 @@ from importlib import import_module, reload as reload_module
|
||||
from types import ModuleType
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
from ProviderCore.commands import get_primary_command_object
|
||||
from ProviderCore.registry import get_plugin
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -71,17 +72,22 @@ def _normalize_mod_name(mod_name: str) -> str:
|
||||
|
||||
|
||||
def import_cmd_module(mod_name: str, *, reload_loaded: bool = False):
|
||||
"""Import a cmdlet/native module from cmdnat or cmdlet packages."""
|
||||
"""Import a cmdlet/command module from legacy or plugin-owned packages."""
|
||||
normalized = _normalize_mod_name(mod_name)
|
||||
if not normalized:
|
||||
return None
|
||||
for package in ("cmdnat", "cmdlet", None):
|
||||
for qualified in (
|
||||
f"plugins.{normalized}.commands",
|
||||
f"cmdnat.{normalized}",
|
||||
f"cmdlet.{normalized}",
|
||||
normalized,
|
||||
):
|
||||
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
|
||||
# import the third-party `mpv` package (python-mpv) which can raise
|
||||
# OSError if system libmpv is missing.
|
||||
if package is None and normalized == "mpv":
|
||||
if qualified == normalized and normalized == "mpv":
|
||||
try:
|
||||
if reload_loaded and "MPV" in sys.modules:
|
||||
return reload_module(sys.modules["MPV"])
|
||||
@@ -90,7 +96,6 @@ def import_cmd_module(mod_name: str, *, reload_loaded: bool = False):
|
||||
# Local MPV package not present; fall back to the normal bare import.
|
||||
pass
|
||||
|
||||
qualified = f"{package}.{normalized}" if package else normalized
|
||||
if reload_loaded and qualified in sys.modules:
|
||||
return reload_module(sys.modules[qualified])
|
||||
return import_module(qualified)
|
||||
@@ -151,7 +156,7 @@ def get_cmdlet_metadata(
|
||||
ensure_registry_loaded()
|
||||
normalized = cmd_name.replace("-", "_")
|
||||
mod = import_cmd_module(normalized)
|
||||
data = getattr(mod, "CMDLET", None) if mod else None
|
||||
data = get_primary_command_object(mod) if mod else None
|
||||
|
||||
if data is None:
|
||||
try:
|
||||
@@ -161,7 +166,7 @@ def get_cmdlet_metadata(
|
||||
owner_mod = getattr(reg_fn, "__module__", "")
|
||||
if owner_mod:
|
||||
owner = import_module(owner_mod)
|
||||
data = getattr(owner, "CMDLET", None)
|
||||
data = get_primary_command_object(owner)
|
||||
except Exception as exc:
|
||||
logger.exception("Registry fallback failed while resolving cmdlet %s: %s", cmd_name, exc)
|
||||
data = None
|
||||
@@ -371,7 +376,7 @@ def get_cmdlet_arg_choices(
|
||||
ids = []
|
||||
|
||||
if ids:
|
||||
# Try to resolve names via Provider.matrix if config provides auth info
|
||||
# Try to resolve names via the Matrix plugin if config provides auth info
|
||||
try:
|
||||
hs = matrix_conf.get("homeserver")
|
||||
token = matrix_conf.get("access_token")
|
||||
|
||||
+7
-3
@@ -33,13 +33,17 @@ class CmdletArg:
|
||||
return value
|
||||
|
||||
def to_flags(self) -> tuple[str, ...]:
|
||||
flags = [f"--{self.name}", f"-{self.name}"]
|
||||
normalized_name = str(self.name or "").lstrip("-")
|
||||
if not normalized_name:
|
||||
return tuple()
|
||||
|
||||
flags = [f"--{normalized_name}", f"-{normalized_name}"]
|
||||
if self.alias:
|
||||
flags.append(f"-{self.alias}")
|
||||
|
||||
if self.type == "flag":
|
||||
flags.append(f"--no-{self.name}")
|
||||
flags.append(f"-no{self.name}")
|
||||
flags.append(f"--no-{normalized_name}")
|
||||
flags.append(f"-no{normalized_name}")
|
||||
if self.alias:
|
||||
flags.append(f"-n{self.alias}")
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable, List, Optional, Sequence
|
||||
|
||||
VALUE_ARG_FLAGS = frozenset({"-value", "--value", "-set-value", "--set-value"})
|
||||
|
||||
|
||||
def extract_piped_value(result: Any) -> Optional[str]:
|
||||
if isinstance(result, str):
|
||||
return result.strip() if result.strip() else None
|
||||
if isinstance(result, (int, float)):
|
||||
return str(result)
|
||||
if isinstance(result, dict):
|
||||
value = result.get("value")
|
||||
if value is not None:
|
||||
return str(value).strip()
|
||||
return None
|
||||
|
||||
|
||||
def extract_arg_value(
|
||||
args: Sequence[str],
|
||||
*,
|
||||
flags: Iterable[str],
|
||||
allow_positional: bool = False,
|
||||
) -> Optional[str]:
|
||||
if not args:
|
||||
return None
|
||||
|
||||
tokens = [str(tok) for tok in args if tok is not None]
|
||||
normalized_flags = {
|
||||
str(flag).strip().lower() for flag in flags if str(flag).strip()
|
||||
}
|
||||
if not normalized_flags:
|
||||
return None
|
||||
|
||||
for idx, tok in enumerate(tokens):
|
||||
text = tok.strip()
|
||||
if not text:
|
||||
continue
|
||||
low = text.lower()
|
||||
if low in normalized_flags and idx + 1 < len(tokens):
|
||||
candidate = str(tokens[idx + 1]).strip()
|
||||
if candidate:
|
||||
return candidate
|
||||
if "=" in low:
|
||||
head, value = low.split("=", 1)
|
||||
if head in normalized_flags and value:
|
||||
return value.strip()
|
||||
|
||||
if not allow_positional:
|
||||
return None
|
||||
|
||||
for tok in tokens:
|
||||
text = str(tok).strip()
|
||||
if text and not text.startswith("-"):
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def extract_value_arg(args: Sequence[str]) -> Optional[str]:
|
||||
return extract_arg_value(args, flags=VALUE_ARG_FLAGS, allow_positional=True)
|
||||
|
||||
|
||||
def has_flag(args: Sequence[str], flag: str) -> bool:
|
||||
try:
|
||||
want = str(flag or "").strip().lower()
|
||||
if not want:
|
||||
return False
|
||||
return any(str(arg).strip().lower() == want for arg in (args or []))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def normalize_to_list(value: Any) -> List[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value]
|
||||
@@ -769,14 +769,6 @@ def set_last_result_table_overlay(
|
||||
state.display_items = items or []
|
||||
state.display_subject = subject
|
||||
|
||||
def set_last_result_table_preserve_history(
|
||||
result_table: Optional[Any],
|
||||
items: Optional[List[Any]] = None,
|
||||
subject: Optional[Any] = None
|
||||
) -> None:
|
||||
"""Compatibility alias for set_last_result_table_overlay."""
|
||||
set_last_result_table_overlay(result_table, items=items, subject=subject)
|
||||
|
||||
def set_last_result_items_only(items: Optional[List[Any]]) -> None:
|
||||
"""
|
||||
Store items for @N selection WITHOUT affecting history or saved search data.
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from SYS.config import global_config
|
||||
from ProviderCore.registry import get_plugin_class, list_plugins
|
||||
from Store.registry import _discover_store_classes, _required_keys_for
|
||||
from Store.registry import _discover_store_classes, _required_keys_for, _resolve_store_class
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,8 +54,7 @@ def _call_schema(owner: Any, label: str) -> List[ConfigField]:
|
||||
|
||||
|
||||
def get_store_schema(store_type: str) -> List[ConfigField]:
|
||||
classes = _discover_store_classes()
|
||||
cls = classes.get(str(store_type or "").strip())
|
||||
cls = _resolve_store_class(str(store_type or "").strip())
|
||||
if cls is None:
|
||||
return []
|
||||
return _call_schema(cls, f"store '{store_type}'")
|
||||
@@ -115,8 +114,7 @@ def build_default_store_config(store_type: str, instance_name: str) -> Dict[str,
|
||||
config[key] = field.get("default", "")
|
||||
return config
|
||||
|
||||
classes = _discover_store_classes()
|
||||
cls = classes.get(str(store_type or "").strip())
|
||||
cls = _resolve_store_class(str(store_type or "").strip())
|
||||
if cls is None:
|
||||
return config
|
||||
for required_key in _required_keys_for(cls):
|
||||
@@ -174,8 +172,7 @@ def get_required_config_keys(item_type: str, item_name: str) -> List[str]:
|
||||
|
||||
if normalized_type.startswith("store-"):
|
||||
store_type = normalized_type.replace("store-", "", 1)
|
||||
classes = _discover_store_classes()
|
||||
cls = classes.get(store_type)
|
||||
cls = _resolve_store_class(store_type)
|
||||
if cls is not None:
|
||||
for required_key in _required_keys_for(cls):
|
||||
_add_key(required_key)
|
||||
|
||||
+72
-1
@@ -13,7 +13,7 @@ Features:
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Callable, Set
|
||||
from typing import Any, Dict, List, Optional, Callable, Set, Tuple
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
@@ -94,6 +94,42 @@ def _partition_detail_tags(tags: Any) -> tuple[List[str], List[str]]:
|
||||
return namespace_tags, freeform_tags
|
||||
|
||||
|
||||
def _extract_namespace_sort_values(tags: Any, namespace: str) -> List[str]:
|
||||
wanted = str(namespace or "").strip().casefold()
|
||||
if not wanted:
|
||||
return []
|
||||
|
||||
values: List[str] = []
|
||||
for tag in _normalize_detail_tags(tags):
|
||||
ns, sep, value = str(tag).partition(":")
|
||||
if not sep:
|
||||
continue
|
||||
if ns.strip().casefold() != wanted:
|
||||
continue
|
||||
clean_value = value.strip()
|
||||
if clean_value:
|
||||
values.append(clean_value)
|
||||
return values
|
||||
|
||||
|
||||
def _namespace_sort_key(tags: Any, namespace: str, *, numeric: bool = False) -> Tuple[int, Any, str]:
|
||||
values = _extract_namespace_sort_values(tags, namespace)
|
||||
if not values:
|
||||
return (1, float("inf") if numeric else "", "")
|
||||
|
||||
primary = values[0]
|
||||
if numeric:
|
||||
match = re.search(r"-?\d+(?:\.\d+)?", primary)
|
||||
if match:
|
||||
try:
|
||||
return (0, float(match.group(0)), primary.casefold())
|
||||
except Exception:
|
||||
pass
|
||||
return (0, float("inf"), primary.casefold())
|
||||
|
||||
return (0, primary.casefold(), primary.casefold())
|
||||
|
||||
|
||||
def _chunk_detail_tags(tags: List[str], columns: int) -> List[List[str]]:
|
||||
column_count = max(1, int(columns or 1))
|
||||
rows: List[List[str]] = []
|
||||
@@ -954,6 +990,41 @@ class Table:
|
||||
|
||||
return self
|
||||
|
||||
def sort_by_tag_namespace(self, namespace: str, *, numeric: bool = False, reverse: bool = False) -> "Table":
|
||||
"""Sort rows by the first value found for a tag namespace.
|
||||
|
||||
Looks first at row payload tag metadata, then falls back to the visible Tag column.
|
||||
When ``numeric`` is True, the first numeric token inside the namespace value is used.
|
||||
"""
|
||||
if getattr(self, "preserve_order", False):
|
||||
return self
|
||||
|
||||
wanted = str(namespace or "").strip()
|
||||
if not wanted:
|
||||
return self
|
||||
|
||||
def _row_tags(row: Row) -> Any:
|
||||
payload = getattr(row, "payload", None)
|
||||
if isinstance(payload, dict):
|
||||
for key in ("tag", "tags", "tag_summary"):
|
||||
value = payload.get(key)
|
||||
if value:
|
||||
return value
|
||||
metadata = payload.get("metadata")
|
||||
if isinstance(metadata, dict):
|
||||
for key in ("tag", "tags", "tag_summary"):
|
||||
value = metadata.get(key)
|
||||
if value:
|
||||
return value
|
||||
tag_column = row.get_column("Tag")
|
||||
return tag_column or []
|
||||
|
||||
self.rows.sort(
|
||||
key=lambda row: _namespace_sort_key(_row_tags(row), wanted, numeric=bool(numeric)),
|
||||
reverse=bool(reverse),
|
||||
)
|
||||
return self
|
||||
|
||||
def add_result(self, result: Any) -> "Table":
|
||||
"""Add a result object (SearchResult, PipeObject, ResultItem, TagItem, or dict) as a row.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+99
-3
@@ -25,6 +25,7 @@ from Store._base import Store as BaseStore
|
||||
_SHA256_HEX_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
|
||||
_DISCOVERED_CLASSES_CACHE: Optional[Dict[str, Type[BaseStore]]] = None
|
||||
_PLUGIN_DISCOVERED_CLASSES_CACHE: Dict[str, Optional[Type[BaseStore]]] = {}
|
||||
|
||||
# Backends that failed to initialize earlier in the current process.
|
||||
# Keyed by (store_type, instance_key) where instance_key is the name used under config.store.<type>.<instance_key>.
|
||||
@@ -85,6 +86,101 @@ def _discover_store_classes() -> Dict[str, Type[BaseStore]]:
|
||||
return discovered
|
||||
|
||||
|
||||
def _extract_store_classes(owner: Any) -> Dict[str, Type[BaseStore]]:
|
||||
discovered: Dict[str, Type[BaseStore]] = {}
|
||||
|
||||
def _add_candidate(key: Any, candidate: Any) -> None:
|
||||
if not inspect.isclass(candidate):
|
||||
return
|
||||
if candidate is BaseStore:
|
||||
return
|
||||
if not issubclass(candidate, BaseStore):
|
||||
return
|
||||
normalized = _normalize_store_type(str(key or candidate.__name__))
|
||||
if normalized:
|
||||
discovered[normalized] = candidate
|
||||
|
||||
if owner is None:
|
||||
return discovered
|
||||
|
||||
if inspect.isclass(owner):
|
||||
_add_candidate(None, owner)
|
||||
return discovered
|
||||
|
||||
if isinstance(owner, dict):
|
||||
for key, candidate in owner.items():
|
||||
_add_candidate(key, candidate)
|
||||
return discovered
|
||||
|
||||
if isinstance(owner, (list, tuple, set, frozenset)):
|
||||
for candidate in owner:
|
||||
_add_candidate(None, candidate)
|
||||
return discovered
|
||||
|
||||
try:
|
||||
for key, candidate in vars(owner).items():
|
||||
_add_candidate(key, candidate)
|
||||
except Exception:
|
||||
pass
|
||||
return discovered
|
||||
|
||||
|
||||
def _discover_plugin_store_class(store_type: str) -> Optional[Type[BaseStore]]:
|
||||
normalized = _normalize_store_type(store_type)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
cached = _PLUGIN_DISCOVERED_CLASSES_CACHE.get(normalized, None)
|
||||
if normalized in _PLUGIN_DISCOVERED_CLASSES_CACHE:
|
||||
return cached
|
||||
|
||||
try:
|
||||
plugin_module = importlib.import_module(f"plugins.{normalized}")
|
||||
except Exception:
|
||||
_PLUGIN_DISCOVERED_CLASSES_CACHE[normalized] = None
|
||||
return None
|
||||
|
||||
discovered: Dict[str, Type[BaseStore]] = {}
|
||||
|
||||
backend_hook = getattr(plugin_module, "get_store_backend_classes", None)
|
||||
if callable(backend_hook):
|
||||
try:
|
||||
discovered.update(_extract_store_classes(backend_hook()))
|
||||
except Exception as exc:
|
||||
debug(f"[Store] Failed to load plugin store backends for '{normalized}': {exc}")
|
||||
|
||||
discovered.update(_extract_store_classes(getattr(plugin_module, "STORE_BACKENDS", None)))
|
||||
|
||||
if normalized not in discovered:
|
||||
discovered.update(_extract_store_classes(plugin_module))
|
||||
|
||||
resolved = discovered.get(normalized)
|
||||
if resolved is None and len(discovered) == 1:
|
||||
resolved = next(iter(discovered.values()))
|
||||
|
||||
_PLUGIN_DISCOVERED_CLASSES_CACHE[normalized] = resolved
|
||||
return resolved
|
||||
|
||||
|
||||
def _resolve_store_class(
|
||||
store_type: str,
|
||||
classes_by_type: Optional[Dict[str, Type[BaseStore]]] = None,
|
||||
) -> Optional[Type[BaseStore]]:
|
||||
normalized = _normalize_store_type(store_type)
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
plugin_resolved = _discover_plugin_store_class(normalized)
|
||||
if plugin_resolved is not None:
|
||||
return plugin_resolved
|
||||
|
||||
discovered = classes_by_type if classes_by_type is not None else _discover_store_classes()
|
||||
resolved = discovered.get(normalized)
|
||||
if resolved is not None:
|
||||
return resolved
|
||||
return None
|
||||
|
||||
|
||||
def _required_keys_for(store_cls: Type[BaseStore]) -> list[str]:
|
||||
# Support new config_schema() schema
|
||||
if hasattr(store_cls, "config_schema") and callable(store_cls.config_schema):
|
||||
@@ -170,7 +266,7 @@ class Store:
|
||||
store_type = _normalize_store_type(str(raw_store_type))
|
||||
if store_type == "folder":
|
||||
continue
|
||||
store_cls = classes_by_type.get(store_type)
|
||||
store_cls = _resolve_store_class(store_type, classes_by_type)
|
||||
if store_cls is None:
|
||||
# Skip provider-only names without debug warning
|
||||
if store_type not in _PROVIDER_ONLY_STORE_NAMES and not self._suppress_debug:
|
||||
@@ -373,7 +469,7 @@ def list_configured_backend_names(config: Optional[Dict[str, Any]]) -> list[str]
|
||||
if store_type == "folder" or store_type in _PROVIDER_ONLY_STORE_NAMES:
|
||||
continue
|
||||
|
||||
store_cls = classes_by_type.get(store_type)
|
||||
store_cls = _resolve_store_class(store_type, classes_by_type)
|
||||
if store_cls is None:
|
||||
continue
|
||||
|
||||
@@ -417,7 +513,7 @@ def get_backend_instance(config: Optional[Dict[str, Any]], backend_name: str, *,
|
||||
if not isinstance(instances, dict):
|
||||
continue
|
||||
store_type = _normalize_store_type(str(raw_store_type))
|
||||
store_cls = classes_by_type.get(store_type)
|
||||
store_cls = _resolve_store_class(store_type, classes_by_type)
|
||||
if store_cls is None:
|
||||
continue
|
||||
|
||||
|
||||
@@ -103,6 +103,17 @@ def _register_native_commands() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _register_plugin_commands() -> None:
|
||||
try:
|
||||
from ProviderCore.commands import register_plugin_commands
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
register_plugin_commands(REGISTRY)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_cmdlet_modules_loaded(force: bool = False) -> None:
|
||||
global _MODULES_LOADED
|
||||
|
||||
@@ -115,4 +126,5 @@ def ensure_cmdlet_modules_loaded(force: bool = False) -> None:
|
||||
_load_root_modules()
|
||||
_load_helper_modules()
|
||||
_register_native_commands()
|
||||
_register_plugin_commands()
|
||||
_MODULES_LOADED = True
|
||||
|
||||
+421
-5
@@ -105,9 +105,13 @@ class CmdletArg:
|
||||
storage_flags = SharedArgs.STORAGE.to_flags()
|
||||
# Returns: ('--storage', '-storage', '-s')
|
||||
"""
|
||||
normalized_name = str(self.name or "").lstrip("-")
|
||||
if not normalized_name:
|
||||
return tuple()
|
||||
|
||||
flags = [
|
||||
f"--{self.name}",
|
||||
f"-{self.name}"
|
||||
f"--{normalized_name}",
|
||||
f"-{normalized_name}"
|
||||
] # Both double-dash and single-dash variants
|
||||
|
||||
# Add short form if alias exists
|
||||
@@ -116,8 +120,8 @@ class CmdletArg:
|
||||
|
||||
# Add negation forms for flag type
|
||||
if self.type == "flag":
|
||||
flags.append(f"--no-{self.name}")
|
||||
flags.append(f"-no{self.name}") # Single-dash negation variant
|
||||
flags.append(f"--no-{normalized_name}")
|
||||
flags.append(f"-no{normalized_name}") # Single-dash negation variant
|
||||
if self.alias:
|
||||
flags.append(f"-n{self.alias}")
|
||||
|
||||
@@ -1658,6 +1662,59 @@ def parse_tag_arguments(arguments: Sequence[str]) -> List[str]:
|
||||
List of normalized tag strings (empty strings filtered out)
|
||||
"""
|
||||
|
||||
def _split_top_level_commas(text: str) -> List[str]:
|
||||
segments: List[str] = []
|
||||
current: List[str] = []
|
||||
paren_depth = 0
|
||||
angle_depth = 0
|
||||
quote: Optional[str] = None
|
||||
escape = False
|
||||
|
||||
for ch in text:
|
||||
if escape:
|
||||
current.append(ch)
|
||||
escape = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
current.append(ch)
|
||||
escape = True
|
||||
continue
|
||||
if quote:
|
||||
current.append(ch)
|
||||
if ch == quote:
|
||||
quote = None
|
||||
continue
|
||||
if ch in {"'", '"'}:
|
||||
current.append(ch)
|
||||
quote = ch
|
||||
continue
|
||||
if ch == "(":
|
||||
paren_depth += 1
|
||||
current.append(ch)
|
||||
continue
|
||||
if ch == ")":
|
||||
paren_depth = max(0, paren_depth - 1)
|
||||
current.append(ch)
|
||||
continue
|
||||
if ch == "<":
|
||||
angle_depth += 1
|
||||
current.append(ch)
|
||||
continue
|
||||
if ch == ">":
|
||||
angle_depth = max(0, angle_depth - 1)
|
||||
current.append(ch)
|
||||
continue
|
||||
if ch == "," and paren_depth == 0 and angle_depth == 0:
|
||||
segments.append("".join(current).strip())
|
||||
current = []
|
||||
continue
|
||||
current.append(ch)
|
||||
|
||||
tail = "".join(current).strip()
|
||||
if tail or segments:
|
||||
segments.append(tail)
|
||||
return segments
|
||||
|
||||
def _expand_pipe_namespace(text: str) -> List[str]:
|
||||
parts = text.split("|")
|
||||
expanded: List[str] = []
|
||||
@@ -1684,7 +1741,7 @@ def parse_tag_arguments(arguments: Sequence[str]) -> List[str]:
|
||||
|
||||
tags: List[str] = []
|
||||
for argument in arguments:
|
||||
for token in argument.split(","):
|
||||
for token in _split_top_level_commas(str(argument)):
|
||||
text = token.strip()
|
||||
if not text:
|
||||
continue
|
||||
@@ -1704,6 +1761,365 @@ def parse_tag_arguments(arguments: Sequence[str]) -> List[str]:
|
||||
return tags
|
||||
|
||||
|
||||
_TAG_VALUE_TEMPLATE_RE = re.compile(r"#\(([^)]+)\)")
|
||||
_TAG_VALUE_FUNCTION_RE = re.compile(r"<([a-zA-Z_][a-zA-Z0-9_-]*)\((.*?)\)>")
|
||||
|
||||
|
||||
def _normalize_tag_value_template_name(value: Any) -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
except Exception:
|
||||
text = " ".join(text.split())
|
||||
return text
|
||||
|
||||
|
||||
def _tag_value_template_keys(value: Any) -> list[str]:
|
||||
normalized = _normalize_tag_value_template_name(value)
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
keys = [normalized]
|
||||
|
||||
trimmed_hash = re.sub(r"\s*#+\s*$", "", normalized).strip()
|
||||
if trimmed_hash and trimmed_hash not in keys:
|
||||
keys.append(trimmed_hash)
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
def _add_tag_values_to_lookup(lookup: Dict[str, List[str]], tag_text: Any) -> None:
|
||||
text = str(tag_text or "").strip()
|
||||
if not text or ":" not in text:
|
||||
return
|
||||
if _TAG_VALUE_TEMPLATE_RE.search(text) or _TAG_VALUE_FUNCTION_RE.search(text):
|
||||
return
|
||||
|
||||
namespace, value = text.split(":", 1)
|
||||
value_text = str(value or "").strip()
|
||||
if not value_text:
|
||||
return
|
||||
|
||||
for key in _tag_value_template_keys(namespace):
|
||||
values = lookup.setdefault(key, [])
|
||||
if value_text not in values:
|
||||
values.append(value_text)
|
||||
|
||||
|
||||
def build_tag_value_lookup(
|
||||
tags: Optional[Iterable[Any]],
|
||||
*,
|
||||
result: Any = None,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Build a placeholder lookup from existing tags and lightweight result fields.
|
||||
|
||||
Placeholder lookups use ``#(namespace)`` syntax. Namespace matching is
|
||||
case-insensitive and trims repeated whitespace. A trailing ``#`` in the
|
||||
placeholder is ignored so inputs like ``#(track #)`` can resolve ``track:9``.
|
||||
"""
|
||||
|
||||
lookup: Dict[str, List[str]] = {}
|
||||
for tag in tags or []:
|
||||
_add_tag_values_to_lookup(lookup, tag)
|
||||
|
||||
title_text = extract_title_from_result(result)
|
||||
if title_text:
|
||||
_add_tag_values_to_lookup(lookup, f"title:{title_text}")
|
||||
|
||||
return lookup
|
||||
|
||||
|
||||
def _split_tag_value_function_args(value: Any) -> list[str]:
|
||||
text = str(value or "")
|
||||
args: list[str] = []
|
||||
current: list[str] = []
|
||||
depth = 0
|
||||
quote: Optional[str] = None
|
||||
escape = False
|
||||
|
||||
for ch in text:
|
||||
if escape:
|
||||
current.append(ch)
|
||||
escape = False
|
||||
continue
|
||||
if ch == "\\":
|
||||
current.append(ch)
|
||||
escape = True
|
||||
continue
|
||||
if quote:
|
||||
current.append(ch)
|
||||
if ch == quote:
|
||||
quote = None
|
||||
continue
|
||||
if ch in {"'", '"'}:
|
||||
current.append(ch)
|
||||
quote = ch
|
||||
continue
|
||||
if ch in {"(", "[", "{"}:
|
||||
depth += 1
|
||||
current.append(ch)
|
||||
continue
|
||||
if ch in {")", "]", "}"}:
|
||||
depth = max(0, depth - 1)
|
||||
current.append(ch)
|
||||
continue
|
||||
if ch == "," and depth == 0:
|
||||
args.append("".join(current).strip())
|
||||
current = []
|
||||
continue
|
||||
current.append(ch)
|
||||
|
||||
tail = "".join(current).strip()
|
||||
if tail or args:
|
||||
args.append(tail)
|
||||
return args
|
||||
|
||||
|
||||
def _strip_tag_value_function_arg(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}:
|
||||
return text[1:-1]
|
||||
return text
|
||||
|
||||
|
||||
def _padding_width_from_spec(value: Any) -> Optional[int]:
|
||||
spec = _strip_tag_value_function_arg(value)
|
||||
if not spec:
|
||||
return None
|
||||
if re.fullmatch(r"0+", spec):
|
||||
return len(spec)
|
||||
if spec.isdigit():
|
||||
try:
|
||||
width = int(spec)
|
||||
except Exception:
|
||||
return None
|
||||
return width if width > 0 else None
|
||||
return None
|
||||
|
||||
|
||||
def _replace_tag_value_placeholders(
|
||||
value: Any,
|
||||
lookup: Dict[str, List[str]],
|
||||
*,
|
||||
preserve_unresolved: bool,
|
||||
) -> tuple[str, bool]:
|
||||
text = str(value or "")
|
||||
unresolved = False
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
nonlocal unresolved
|
||||
keys = _tag_value_template_keys(match.group(1) or "")
|
||||
values: List[str] = []
|
||||
for key in keys:
|
||||
for candidate in lookup.get(key, []):
|
||||
if candidate not in values:
|
||||
values.append(candidate)
|
||||
if not values:
|
||||
unresolved = True
|
||||
return match.group(0) if preserve_unresolved else ""
|
||||
return ", ".join(values)
|
||||
|
||||
return _TAG_VALUE_TEMPLATE_RE.sub(_replace, text), unresolved
|
||||
|
||||
|
||||
def _coerce_tag_value_integer(value: Any) -> Optional[int]:
|
||||
text = _strip_tag_value_function_arg(value)
|
||||
if not text:
|
||||
return None
|
||||
if not re.fullmatch(r"[+-]?\d+", text):
|
||||
return None
|
||||
try:
|
||||
return int(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _apply_tag_value_function(
|
||||
name: str,
|
||||
args: Sequence[str],
|
||||
*,
|
||||
lookup: Dict[str, List[str]],
|
||||
) -> Optional[str]:
|
||||
func = str(name or "").strip().lower()
|
||||
|
||||
resolved_values: list[str] = []
|
||||
unresolved_flags: list[bool] = []
|
||||
for arg in args:
|
||||
rendered, unresolved = _replace_tag_value_placeholders(
|
||||
arg,
|
||||
lookup,
|
||||
preserve_unresolved=True,
|
||||
)
|
||||
resolved_values.append(_strip_tag_value_function_arg(rendered))
|
||||
unresolved_flags.append(unresolved)
|
||||
|
||||
if func in {"padding", "pad", "zfill"}:
|
||||
if len(resolved_values) != 2 or any(unresolved_flags):
|
||||
return None
|
||||
width = _padding_width_from_spec(resolved_values[0])
|
||||
if width is None:
|
||||
return None
|
||||
return str(resolved_values[1]).zfill(width)
|
||||
|
||||
if func == "default":
|
||||
if len(resolved_values) != 2:
|
||||
return None
|
||||
primary = resolved_values[0]
|
||||
fallback = resolved_values[1]
|
||||
if not unresolved_flags[0] and str(primary).strip():
|
||||
return str(primary)
|
||||
if unresolved_flags[1]:
|
||||
return None
|
||||
return str(fallback)
|
||||
|
||||
if func == "replace":
|
||||
if len(resolved_values) != 3 or any(unresolved_flags):
|
||||
return None
|
||||
return str(resolved_values[0]).replace(
|
||||
str(resolved_values[1]),
|
||||
str(resolved_values[2]),
|
||||
)
|
||||
|
||||
if func in {"increment", "inc", "add"}:
|
||||
if len(resolved_values) not in {1, 2}:
|
||||
return None
|
||||
if unresolved_flags[0]:
|
||||
return None
|
||||
base_value = _coerce_tag_value_integer(resolved_values[0])
|
||||
if base_value is None:
|
||||
return None
|
||||
step_value = 1
|
||||
if len(resolved_values) == 2:
|
||||
if unresolved_flags[1]:
|
||||
return None
|
||||
parsed_step = _coerce_tag_value_integer(resolved_values[1])
|
||||
if parsed_step is None:
|
||||
return None
|
||||
step_value = parsed_step
|
||||
return str(base_value + step_value)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _render_tag_value_function_templates(
|
||||
value: Any,
|
||||
*,
|
||||
lookup: Dict[str, List[str]],
|
||||
) -> tuple[str, bool]:
|
||||
text = str(value or "")
|
||||
unresolved = False
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
nonlocal unresolved
|
||||
func_name = match.group(1) or ""
|
||||
func_args = _split_tag_value_function_args(match.group(2) or "")
|
||||
rendered = _apply_tag_value_function(
|
||||
func_name,
|
||||
func_args,
|
||||
lookup=lookup,
|
||||
)
|
||||
if rendered is None:
|
||||
unresolved = True
|
||||
return match.group(0)
|
||||
return rendered
|
||||
|
||||
previous = None
|
||||
rendered = text
|
||||
while previous != rendered and _TAG_VALUE_FUNCTION_RE.search(rendered):
|
||||
previous = rendered
|
||||
rendered = _TAG_VALUE_FUNCTION_RE.sub(_replace, rendered)
|
||||
if unresolved:
|
||||
break
|
||||
return rendered, unresolved
|
||||
|
||||
|
||||
def render_tag_value_templates(
|
||||
tags: Sequence[Any],
|
||||
*,
|
||||
existing_tags: Optional[Iterable[Any]] = None,
|
||||
result: Any = None,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Resolve ``#(namespace)`` placeholders and ``<transform(...)>`` functions.
|
||||
|
||||
Returns ``(resolved_tags, unresolved_templates)``. Tags whose placeholders
|
||||
cannot be fully resolved are omitted from ``resolved_tags`` and returned in
|
||||
``unresolved_templates`` so callers can warn or summarize skipped items.
|
||||
|
||||
Currently supported transforms:
|
||||
- ``<padding(00,#(episode))>`` or ``<pad(2,#(episode))>`` for zero-padding
|
||||
- ``<default(#(season),0)>`` to fall back when a placeholder is missing
|
||||
- ``<replace(#(title),old,new)>`` for simple substring replacement
|
||||
- ``<increment(#(episode),1)>`` for integer arithmetic
|
||||
"""
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
lookup = build_tag_value_lookup(existing_tags, result=result)
|
||||
|
||||
for raw_tag in tags or []:
|
||||
text = str(raw_tag or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
has_template = bool(
|
||||
_TAG_VALUE_TEMPLATE_RE.search(text)
|
||||
or _TAG_VALUE_FUNCTION_RE.search(text)
|
||||
)
|
||||
entry = {
|
||||
"raw": text,
|
||||
"resolved": None,
|
||||
"has_template": has_template,
|
||||
}
|
||||
if not has_template:
|
||||
entry["resolved"] = text
|
||||
_add_tag_values_to_lookup(lookup, text)
|
||||
entries.append(entry)
|
||||
|
||||
progress = True
|
||||
while progress:
|
||||
progress = False
|
||||
for entry in entries:
|
||||
if entry["resolved"] is not None or not entry["has_template"]:
|
||||
continue
|
||||
|
||||
rendered, unresolved = _replace_tag_value_placeholders(
|
||||
entry["raw"],
|
||||
lookup,
|
||||
preserve_unresolved=bool(_TAG_VALUE_FUNCTION_RE.search(str(entry["raw"]))),
|
||||
)
|
||||
|
||||
rendered, function_unresolved = _render_tag_value_function_templates(
|
||||
rendered,
|
||||
lookup=lookup,
|
||||
)
|
||||
if function_unresolved:
|
||||
continue
|
||||
|
||||
if unresolved and _TAG_VALUE_TEMPLATE_RE.search(rendered):
|
||||
continue
|
||||
|
||||
rendered = rendered.strip()
|
||||
if not rendered:
|
||||
entry["resolved"] = ""
|
||||
progress = True
|
||||
continue
|
||||
|
||||
entry["resolved"] = rendered
|
||||
_add_tag_values_to_lookup(lookup, rendered)
|
||||
progress = True
|
||||
|
||||
resolved_tags = merge_sequences(
|
||||
[entry["resolved"] for entry in entries if isinstance(entry.get("resolved"), str) and entry.get("resolved")],
|
||||
case_sensitive=True,
|
||||
)
|
||||
unresolved_templates = [
|
||||
str(entry["raw"])
|
||||
for entry in entries
|
||||
if entry["has_template"] and not entry.get("resolved")
|
||||
]
|
||||
return resolved_tags, unresolved_templates
|
||||
|
||||
|
||||
def fmt_bytes(n: Optional[int]) -> str:
|
||||
"""Format bytes as human-readable with 1 decimal place (MB/GB).
|
||||
|
||||
|
||||
+111
-19
@@ -337,6 +337,13 @@ class Add_File(Cmdlet):
|
||||
except Exception as exc:
|
||||
debug(f"[add-file] Directory scan failed: {exc}")
|
||||
|
||||
if result is None and not path_arg and not explicit_path_list_results and not dir_scan_results:
|
||||
try:
|
||||
if ctx.get_stage_context() is not None:
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Determine if -store targets a registered backend (vs a filesystem export path).
|
||||
is_storage_backend_location = False
|
||||
if location:
|
||||
@@ -354,6 +361,19 @@ class Add_File(Cmdlet):
|
||||
)
|
||||
return 1
|
||||
|
||||
plugin_storage_backend = None
|
||||
if plugin_name:
|
||||
plugin_storage_backend = Add_File._resolve_plugin_storage_backend(
|
||||
plugin_name,
|
||||
plugin_instance,
|
||||
config,
|
||||
store_instance=storage_registry,
|
||||
)
|
||||
|
||||
effective_storage_backend_name = plugin_storage_backend or (
|
||||
str(location) if location and is_storage_backend_location else None
|
||||
)
|
||||
|
||||
# Decide which items to process.
|
||||
# - If directory scan was performed, use those results
|
||||
# - If user provided -path (and it was not reinterpreted as destination), treat this invocation as single-item.
|
||||
@@ -371,13 +391,6 @@ class Add_File(Cmdlet):
|
||||
else:
|
||||
items_to_process = [result]
|
||||
|
||||
if result is None and not path_arg and not explicit_path_list_results and not dir_scan_results:
|
||||
try:
|
||||
if ctx.get_stage_context() is not None:
|
||||
return 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
total_items = len(items_to_process) if isinstance(items_to_process, list) else 0
|
||||
processed_items = 0
|
||||
try:
|
||||
@@ -549,15 +562,16 @@ class Add_File(Cmdlet):
|
||||
live_progress = None
|
||||
|
||||
want_final_search_file = (
|
||||
bool(is_last_stage) and bool(is_storage_backend_location)
|
||||
and bool(location) and bool(live_progress)
|
||||
bool(is_last_stage)
|
||||
and bool(effective_storage_backend_name)
|
||||
and bool(live_progress)
|
||||
)
|
||||
auto_search_file_after_add = False
|
||||
|
||||
# When ingesting multiple items into a backend store, defer URL association and
|
||||
# apply it once at the end (bulk) to avoid per-item URL API calls.
|
||||
defer_url_association = (
|
||||
bool(is_storage_backend_location) and bool(location)
|
||||
bool(effective_storage_backend_name)
|
||||
and len(items_to_process) > 1
|
||||
)
|
||||
|
||||
@@ -642,7 +656,7 @@ class Add_File(Cmdlet):
|
||||
|
||||
# When using -path (filesystem export), allow all file types.
|
||||
# When using -store (backend), restrict to SUPPORTED_MEDIA_EXTENSIONS.
|
||||
allow_all_files = not (location and is_storage_backend_location)
|
||||
allow_all_files = not bool(effective_storage_backend_name)
|
||||
if not self._validate_source(media_path, allow_all_extensions=allow_all_files):
|
||||
failures += 1
|
||||
continue
|
||||
@@ -653,14 +667,33 @@ class Add_File(Cmdlet):
|
||||
progress.step("ingesting file")
|
||||
|
||||
if plugin_name:
|
||||
code = self._handle_plugin_upload(
|
||||
media_path,
|
||||
plugin_name,
|
||||
plugin_instance,
|
||||
pipe_obj,
|
||||
config,
|
||||
delete_after_item
|
||||
)
|
||||
if effective_storage_backend_name:
|
||||
code = self._handle_storage_backend(
|
||||
item,
|
||||
media_path,
|
||||
effective_storage_backend_name,
|
||||
pipe_obj,
|
||||
config,
|
||||
delete_after_item,
|
||||
collect_payloads=collected_payloads,
|
||||
collect_relationship_pairs=pending_relationship_pairs,
|
||||
defer_url_association=defer_url_association,
|
||||
pending_url_associations=pending_url_associations,
|
||||
defer_tag_association=defer_url_association,
|
||||
pending_tag_associations=pending_tag_associations,
|
||||
suppress_last_stage_overlay=want_final_search_file,
|
||||
auto_search_file=auto_search_file_after_add,
|
||||
store_instance=storage_registry,
|
||||
)
|
||||
else:
|
||||
code = self._handle_plugin_upload(
|
||||
media_path,
|
||||
plugin_name,
|
||||
plugin_instance,
|
||||
pipe_obj,
|
||||
config,
|
||||
delete_after_item
|
||||
)
|
||||
if code == 0:
|
||||
successes += 1
|
||||
else:
|
||||
@@ -1431,6 +1464,65 @@ class Add_File(Cmdlet):
|
||||
normalized = normalized.split(".", 1)[0]
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _resolve_plugin_storage_backend(
|
||||
plugin_name: Optional[Any],
|
||||
instance_name: Optional[Any],
|
||||
config: Dict[str, Any],
|
||||
*,
|
||||
store_instance: Optional[Any] = None,
|
||||
) -> Optional[str]:
|
||||
plugin_key = Add_File._normalize_provider_key(plugin_name)
|
||||
if not plugin_key:
|
||||
return None
|
||||
|
||||
from ProviderCore.registry import get_plugin_with_capability
|
||||
|
||||
file_provider = get_plugin_with_capability(plugin_key, "upload", config)
|
||||
if file_provider is None:
|
||||
return None
|
||||
|
||||
resolver = getattr(file_provider, "resolve_backend", None)
|
||||
if not callable(resolver):
|
||||
return None
|
||||
|
||||
explicit_instance = str(instance_name or "").strip() or None
|
||||
try:
|
||||
storage = store_instance if store_instance is not None else Store(config)
|
||||
except Exception:
|
||||
storage = None
|
||||
|
||||
try:
|
||||
resolved_name, backend = resolver(
|
||||
explicit_instance,
|
||||
storage=storage,
|
||||
require_explicit=bool(explicit_instance),
|
||||
)
|
||||
except TypeError:
|
||||
try:
|
||||
resolved_name, backend = resolver(explicit_instance)
|
||||
except Exception:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if backend is None:
|
||||
return None
|
||||
|
||||
resolved_text = str(resolved_name or explicit_instance or "").strip()
|
||||
if not resolved_text:
|
||||
return None
|
||||
|
||||
checker = getattr(file_provider, "is_backend", None)
|
||||
if callable(checker):
|
||||
try:
|
||||
if not checker(backend, resolved_text):
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return resolved_text
|
||||
|
||||
@staticmethod
|
||||
def _maybe_download_plugin_result(
|
||||
result: Any,
|
||||
|
||||
@@ -22,6 +22,8 @@ SharedArgs = sh.SharedArgs
|
||||
normalize_hash = sh.normalize_hash
|
||||
parse_tag_arguments = sh.parse_tag_arguments
|
||||
expand_tag_groups = sh.expand_tag_groups
|
||||
merge_sequences = sh.merge_sequences
|
||||
render_tag_value_templates = sh.render_tag_value_templates
|
||||
parse_cmdlet_args = sh.parse_cmdlet_args
|
||||
collapse_namespace_tag = sh.collapse_namespace_tag
|
||||
should_show_help = sh.should_show_help
|
||||
@@ -524,6 +526,11 @@ class Add_Tag(Cmdlet):
|
||||
"- The source namespace must already exist in the file being tagged.",
|
||||
"- Target namespaces that already have a value are skipped (not overwritten).",
|
||||
"- Use -extract to derive namespaced tags from the current title (title field or title: tag) using a simple template.",
|
||||
"- Use #(namespace) inside a tag value to insert existing values, e.g. add-tag \"title:#(track) - #(series)\".",
|
||||
"- Use angle-bracket transforms for advanced formatting, e.g. add-tag \"code:e<padding(00,#(episode))>\".",
|
||||
"- Current documented transforms include padding, default, replace, and increment.",
|
||||
"- Template examples assume lowercase tag text; case transforms are intentionally not part of the documented syntax.",
|
||||
"- See docs/tag_template_syntax.md for recipe-style examples and the current shared template syntax.",
|
||||
],
|
||||
exec=self.run,
|
||||
)
|
||||
@@ -655,6 +662,7 @@ class Add_Tag(Cmdlet):
|
||||
# tag ARE provided - apply them to each store-backed result
|
||||
total_added = 0
|
||||
total_modified = 0
|
||||
unresolved_template_count = 0
|
||||
|
||||
store_registry = Store(config, suppress_debug=True)
|
||||
|
||||
@@ -791,6 +799,13 @@ class Add_Tag(Cmdlet):
|
||||
if new_tag.lower() not in existing_lower:
|
||||
item_tag_to_add.append(new_tag)
|
||||
|
||||
item_tag_to_add, unresolved_templates = render_tag_value_templates(
|
||||
item_tag_to_add,
|
||||
existing_tags=merge_sequences(existing_tag_list, item_tag_to_add, case_sensitive=True),
|
||||
result=res,
|
||||
)
|
||||
unresolved_template_count += len(unresolved_templates)
|
||||
|
||||
item_tag_to_add = collapse_namespace_tag(
|
||||
item_tag_to_add,
|
||||
"title",
|
||||
@@ -962,6 +977,13 @@ class Add_Tag(Cmdlet):
|
||||
if new_tag.lower() not in existing_lower:
|
||||
item_tag_to_add.append(new_tag)
|
||||
|
||||
item_tag_to_add, unresolved_templates = render_tag_value_templates(
|
||||
item_tag_to_add,
|
||||
existing_tags=merge_sequences(existing_tag_list, item_tag_to_add, case_sensitive=True),
|
||||
result=res,
|
||||
)
|
||||
unresolved_template_count += len(unresolved_templates)
|
||||
|
||||
item_tag_to_add = collapse_namespace_tag(
|
||||
item_tag_to_add,
|
||||
"title",
|
||||
@@ -1109,6 +1131,12 @@ class Add_Tag(Cmdlet):
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if unresolved_template_count > 0:
|
||||
log(
|
||||
f"[add_tag] skipped {unresolved_template_count} tag template(s) with unresolved #(namespace) placeholders",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+34
-3
@@ -11,6 +11,9 @@ CmdletArg = sh.CmdletArg
|
||||
SharedArgs = sh.SharedArgs
|
||||
normalize_hash = sh.normalize_hash
|
||||
parse_tag_arguments = sh.parse_tag_arguments
|
||||
render_tag_value_templates = sh.render_tag_value_templates
|
||||
merge_sequences = sh.merge_sequences
|
||||
extract_tag_from_result = sh.extract_tag_from_result
|
||||
should_show_help = sh.should_show_help
|
||||
get_field = sh.get_field
|
||||
from SYS.logger import debug, log
|
||||
@@ -133,6 +136,11 @@ CMDLET = Cmdlet(
|
||||
detail=[
|
||||
"- Requires a Hydrus file (hash present) or explicit -query override.",
|
||||
"- Multiple tags can be comma-separated or space-separated.",
|
||||
"- Use #(namespace) inside a tag value to remove a derived tag, e.g. delete-tag \"title:#(track) - #(series)\".",
|
||||
"- Angle-bracket transforms match add-tag syntax, e.g. delete-tag \"code:e<padding(00,#(episode))>\".",
|
||||
"- Current documented transforms include padding, default, replace, and increment.",
|
||||
"- Template examples assume lowercase tag text; case transforms are intentionally not part of the documented syntax.",
|
||||
"- See docs/tag_template_syntax.md for recipe-style examples and the current shared template syntax.",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -225,7 +233,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
store_name = override_store or get_field(result, "store")
|
||||
path = get_field(result, "path") or get_field(result, "target")
|
||||
tags = [str(t) for t in grouped_tags if t]
|
||||
return 0 if _process_deletion(tags, file_hash, path, store_name, config) else 1
|
||||
return 0 if _process_deletion(tags, file_hash, path, store_name, config, result=result) else 1
|
||||
|
||||
if not tags_arg and not has_piped_tag and not has_piped_tag_list:
|
||||
log("Requires at least one tag argument")
|
||||
@@ -316,7 +324,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
item_hash,
|
||||
item_path,
|
||||
item_store,
|
||||
config):
|
||||
config,
|
||||
result=item):
|
||||
success_count += 1
|
||||
|
||||
if success_count > 0:
|
||||
@@ -331,6 +340,7 @@ def _process_deletion(
|
||||
store_name: str | None,
|
||||
config: Dict[str,
|
||||
Any],
|
||||
result: Any = None,
|
||||
) -> bool:
|
||||
"""Helper to execute the deletion logic for a single target."""
|
||||
|
||||
@@ -367,12 +377,33 @@ def _process_deletion(
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
existing_tag_list = merge_sequences(
|
||||
extract_tag_from_result(result),
|
||||
_fetch_existing_tags(),
|
||||
case_sensitive=True,
|
||||
)
|
||||
|
||||
resolved_tags, unresolved_templates = render_tag_value_templates(
|
||||
tags,
|
||||
existing_tags=existing_tag_list,
|
||||
result=result,
|
||||
)
|
||||
if unresolved_templates:
|
||||
log(
|
||||
f"[delete_tag] skipped {len(unresolved_templates)} tag template(s) with unresolved #(namespace) placeholders",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
tags = list(resolved_tags)
|
||||
if not tags:
|
||||
return False
|
||||
|
||||
# Safety: only block if this deletion would remove the final title tag
|
||||
title_tags = [
|
||||
t for t in tags if isinstance(t, str) and t.lower().startswith("title:")
|
||||
]
|
||||
if title_tags:
|
||||
existing_tags = _fetch_existing_tags()
|
||||
existing_tags = existing_tag_list
|
||||
current_titles = [
|
||||
t for t in existing_tags
|
||||
if isinstance(t, str) and t.lower().startswith("title:")
|
||||
|
||||
@@ -1231,7 +1231,7 @@ class search_file(Cmdlet):
|
||||
log(f"No web results found for query: {search_query}", file=sys.stderr)
|
||||
if refresh_mode:
|
||||
try:
|
||||
ctx.set_last_result_table_preserve_history(table, [])
|
||||
ctx.set_last_result_table_overlay(table, [])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
@@ -2089,7 +2089,7 @@ class search_file(Cmdlet):
|
||||
pass
|
||||
|
||||
if refresh_mode:
|
||||
ctx.set_last_result_table_preserve_history(
|
||||
ctx.set_last_result_table_overlay(
|
||||
table,
|
||||
results_list
|
||||
)
|
||||
@@ -2106,7 +2106,7 @@ class search_file(Cmdlet):
|
||||
if refresh_mode:
|
||||
try:
|
||||
table.title = command_title
|
||||
ctx.set_last_result_table_preserve_history(table, [])
|
||||
ctx.set_last_result_table_overlay(table, [])
|
||||
except Exception:
|
||||
pass
|
||||
db.append_worker_stdout(worker_id, _summarize_worker_results([]))
|
||||
@@ -2279,7 +2279,7 @@ class search_file(Cmdlet):
|
||||
if refresh_mode:
|
||||
try:
|
||||
table.title = command_title
|
||||
ctx.set_last_result_table_preserve_history(table, [])
|
||||
ctx.set_last_result_table_overlay(table, [])
|
||||
except Exception:
|
||||
pass
|
||||
db.append_worker_stdout(worker_id, _summarize_worker_results([]))
|
||||
|
||||
+8
-3
@@ -28,15 +28,20 @@ def _register_cmdlet_object(cmdlet_obj, registry: Dict[str, CmdletFn]) -> None:
|
||||
registry[alias.replace("_", "-").lower()] = run_fn
|
||||
|
||||
|
||||
def register_native_commands(registry: Dict[str, CmdletFn]) -> None:
|
||||
"""Import native command modules and register their CMDLET exec functions."""
|
||||
def _iter_legacy_native_module_names() -> list[str]:
|
||||
base_dir = os.path.dirname(__file__)
|
||||
module_names: list[str] = []
|
||||
for filename in os.listdir(base_dir):
|
||||
if not (filename.endswith(".py") and not filename.startswith("_")
|
||||
and filename != "__init__.py"):
|
||||
continue
|
||||
module_names.append(filename[:-3])
|
||||
return module_names
|
||||
|
||||
mod_name = filename[:-3]
|
||||
|
||||
def register_native_commands(registry: Dict[str, CmdletFn]) -> None:
|
||||
"""Import legacy local command modules from cmdnat/ and register them."""
|
||||
for mod_name in _iter_legacy_native_module_names():
|
||||
try:
|
||||
module = import_module(f".{mod_name}", __name__)
|
||||
cmdlet_obj = getattr(module, "CMDLET", None)
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Sequence, Optional
|
||||
|
||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg
|
||||
from SYS.logger import log
|
||||
from SYS import pipeline as ctx
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name=".out-table",
|
||||
summary="Save the current result table to an SVG file.",
|
||||
usage='.out-table -path "C:\\Path\\To\\Dir"',
|
||||
arg=[
|
||||
CmdletArg(
|
||||
"path",
|
||||
type="string",
|
||||
description="Directory (or file path) to write the SVG to",
|
||||
required=True,
|
||||
),
|
||||
],
|
||||
detail=[
|
||||
"Exports the most recent table (overlay/stage/last) as an SVG using Rich.",
|
||||
"Default filename is derived from the table title (sanitized).",
|
||||
"Examples:",
|
||||
'search-file "ext:mp3" | .out-table -path "C:\\Users\\Admin\\Desktop"',
|
||||
'search-file "ext:mp3" | .out-table -path "C:\\Users\\Admin\\Desktop\\my-table.svg"',
|
||||
],
|
||||
)
|
||||
|
||||
_WINDOWS_RESERVED_NAMES = {
|
||||
"con",
|
||||
"prn",
|
||||
"aux",
|
||||
"nul",
|
||||
*(f"com{i}" for i in range(1, 10)),
|
||||
*(f"lpt{i}" for i in range(1, 10)),
|
||||
}
|
||||
_ILLEGAL_FILENAME_CHARS_RE = re.compile(r'[<>:"/\\|?*]')
|
||||
|
||||
|
||||
def _sanitize_filename_base(text: str) -> str:
|
||||
"""Sanitize a string for use as a Windows-friendly filename (no extension)."""
|
||||
s = str(text or "").strip()
|
||||
if not s:
|
||||
return "table"
|
||||
|
||||
# Replace characters illegal on Windows (and generally unsafe cross-platform).
|
||||
s = _ILLEGAL_FILENAME_CHARS_RE.sub(" ", s)
|
||||
|
||||
# Drop control characters.
|
||||
s = "".join(ch for ch in s if ch.isprintable())
|
||||
|
||||
# Collapse whitespace.
|
||||
s = " ".join(s.split()).strip()
|
||||
|
||||
# Windows disallows trailing space/dot.
|
||||
s = s.rstrip(" .")
|
||||
|
||||
if not s:
|
||||
s = "table"
|
||||
|
||||
# Avoid reserved device names.
|
||||
if s.lower() in _WINDOWS_RESERVED_NAMES:
|
||||
s = f"_{s}"
|
||||
|
||||
# Keep it reasonably short.
|
||||
if len(s) > 200:
|
||||
s = s[:200].rstrip(" .")
|
||||
|
||||
return s or "table"
|
||||
|
||||
|
||||
def _resolve_output_path(path_arg: str, *, table_title: str) -> Path:
|
||||
raw = str(path_arg or "").strip()
|
||||
if not raw:
|
||||
raise ValueError("-path is required")
|
||||
|
||||
# Treat trailing slash as directory intent even if it doesn't exist yet.
|
||||
ends_with_sep = raw.endswith((os.sep, os.altsep or ""))
|
||||
|
||||
target = Path(raw)
|
||||
|
||||
if target.exists() and target.is_dir():
|
||||
base = _sanitize_filename_base(table_title)
|
||||
return target / f"{base}.svg"
|
||||
|
||||
if ends_with_sep and not target.suffix:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
base = _sanitize_filename_base(table_title)
|
||||
return target / f"{base}.svg"
|
||||
|
||||
# File path intent.
|
||||
if not target.suffix:
|
||||
return target.with_suffix(".svg")
|
||||
|
||||
if target.suffix.lower() != ".svg":
|
||||
return target.with_suffix(".svg")
|
||||
|
||||
return target
|
||||
|
||||
|
||||
def _get_active_table(piped_result: Any) -> Optional[Any]:
|
||||
# Prefer an explicit ResultTable passed through the pipe, but normally `.out-table`
|
||||
# is used after `@` which pipes item selections (not the table itself).
|
||||
if piped_result is not None and hasattr(piped_result, "__rich__"):
|
||||
# Avoid mistakenly treating a dict/list as a renderable.
|
||||
if piped_result.__class__.__name__ == "ResultTable":
|
||||
return piped_result
|
||||
|
||||
return ctx.get_display_table() or ctx.get_current_stage_table(
|
||||
) or ctx.get_last_result_table()
|
||||
|
||||
|
||||
def _run(piped_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
args_list = [str(a) for a in (args or [])]
|
||||
|
||||
# Simple flag parsing: `.out-table -path <value>`
|
||||
path_arg: Optional[str] = None
|
||||
i = 0
|
||||
while i < len(args_list):
|
||||
low = args_list[i].strip().lower()
|
||||
if low in {"-path",
|
||||
"--path"} and i + 1 < len(args_list):
|
||||
path_arg = args_list[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
if not args_list[i].startswith("-") and path_arg is None:
|
||||
# Allow `.out-table <path>` as a convenience.
|
||||
path_arg = args_list[i]
|
||||
i += 1
|
||||
|
||||
if not path_arg:
|
||||
log("Missing required -path", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
table = _get_active_table(piped_result)
|
||||
if table is None:
|
||||
log("No table available to export", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
title = getattr(table, "title", None)
|
||||
title_text = str(title or "table")
|
||||
|
||||
try:
|
||||
out_path = _resolve_output_path(path_arg, table_title=title_text)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
console = Console(record=True)
|
||||
console.print(table)
|
||||
console.save_svg(str(out_path))
|
||||
|
||||
log(f"Saved table SVG: {out_path}")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log(f"Failed to save table SVG: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
CMDLET.exec = _run
|
||||
+1
-1
@@ -41,7 +41,7 @@ def _run(result: Any, args: List[str], config: Dict[str, Any]) -> int:
|
||||
try:
|
||||
# MPV check
|
||||
try:
|
||||
from MPV.mpv_ipc import MPV
|
||||
from plugins.mpv.mpv_ipc import MPV
|
||||
MPV()
|
||||
mpv_path = shutil.which("mpv")
|
||||
_add_startup_check(startup_table, "ENABLED", "MPV", detail=mpv_path or "Available")
|
||||
|
||||
+350
-6
@@ -1,17 +1,335 @@
|
||||
from typing import Any, Dict, Sequence
|
||||
from __future__ import annotations
|
||||
|
||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Sequence, Tuple
|
||||
|
||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args
|
||||
from SYS.logger import log
|
||||
from SYS.result_table import Column, Table
|
||||
from SYS.rich_display import stdout_console
|
||||
|
||||
|
||||
_NUMERIC_NAMESPACE_HINTS = {
|
||||
"track",
|
||||
"disk",
|
||||
"disc",
|
||||
"episode",
|
||||
"season",
|
||||
"chapter",
|
||||
"volume",
|
||||
"part",
|
||||
}
|
||||
_WINDOWS_RESERVED_NAMES = {
|
||||
"con",
|
||||
"prn",
|
||||
"aux",
|
||||
"nul",
|
||||
*(f"com{i}" for i in range(1, 10)),
|
||||
*(f"lpt{i}" for i in range(1, 10)),
|
||||
}
|
||||
_ILLEGAL_FILENAME_CHARS_RE = re.compile(r'[<>:"/\\|?*]')
|
||||
|
||||
|
||||
def _normalize_bool(value: Any) -> bool:
|
||||
text = str(value or "").strip().lower()
|
||||
return text in {"1", "true", "yes", "on", "y"}
|
||||
|
||||
|
||||
def _parse_table_query(query: Any) -> Dict[str, str]:
|
||||
fields: Dict[str, str] = {}
|
||||
raw = str(query or "").strip()
|
||||
if not raw:
|
||||
return fields
|
||||
|
||||
for chunk in re.split(r"[;,]+", raw):
|
||||
part = str(chunk or "").strip()
|
||||
if not part:
|
||||
continue
|
||||
sep_index = part.find(":")
|
||||
if sep_index < 0:
|
||||
sep_index = part.find("=")
|
||||
if sep_index <= 0:
|
||||
continue
|
||||
key = part[:sep_index].strip().lower()
|
||||
value = part[sep_index + 1 :].strip().strip('"').strip("'")
|
||||
if key:
|
||||
fields[key] = value
|
||||
return fields
|
||||
|
||||
|
||||
def _active_table_bundle(ctx: Any) -> Tuple[Any, str]:
|
||||
display_table = ctx.get_display_table() if hasattr(ctx, "get_display_table") else None
|
||||
if display_table is not None:
|
||||
return display_table, "display"
|
||||
|
||||
current_stage_table = ctx.get_current_stage_table() if hasattr(ctx, "get_current_stage_table") else None
|
||||
if current_stage_table is not None:
|
||||
return current_stage_table, "stage"
|
||||
|
||||
last_result_table = ctx.get_last_result_table() if hasattr(ctx, "get_last_result_table") else None
|
||||
if last_result_table is not None:
|
||||
return last_result_table, "last"
|
||||
|
||||
return None, ""
|
||||
|
||||
|
||||
def _clone_table(source: Any) -> Any:
|
||||
if source is None or not isinstance(source, Table):
|
||||
return source
|
||||
|
||||
cloned = source.copy_with_title(str(getattr(source, "title", "") or ""))
|
||||
for source_row in getattr(source, "rows", []) or []:
|
||||
row = cloned.add_row()
|
||||
row.columns = [
|
||||
Column(col.name, col.value, getattr(col, "width", None))
|
||||
for col in getattr(source_row, "columns", []) or []
|
||||
]
|
||||
row.selection_args = list(getattr(source_row, "selection_args", []) or []) or None
|
||||
row.selection_action = list(getattr(source_row, "selection_action", []) or []) or None
|
||||
row.source_index = getattr(source_row, "source_index", None)
|
||||
row.payload = getattr(source_row, "payload", None)
|
||||
return cloned
|
||||
|
||||
|
||||
def _column_sort_key(value: Any, *, numeric: bool = False) -> Tuple[int, Any, str]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return (1, float("inf") if numeric else "", "")
|
||||
if numeric:
|
||||
match = re.search(r"-?\d+(?:\.\d+)?", text)
|
||||
if match:
|
||||
try:
|
||||
return (0, float(match.group(0)), text.casefold())
|
||||
except Exception:
|
||||
pass
|
||||
return (0, float("inf"), text.casefold())
|
||||
return (0, text.casefold(), text.casefold())
|
||||
|
||||
|
||||
def _sort_by_column(table: Any, column_name: str, *, numeric: bool = False, reverse: bool = False) -> None:
|
||||
if table is None or not hasattr(table, "rows"):
|
||||
return
|
||||
|
||||
wanted = str(column_name or "").strip().lower()
|
||||
if not wanted:
|
||||
return
|
||||
|
||||
if wanted in {"title", "name"} and hasattr(table, "sort_by_title"):
|
||||
table.sort_by_title()
|
||||
if reverse and hasattr(table, "rows"):
|
||||
table.rows.reverse()
|
||||
return
|
||||
|
||||
if wanted == "tag" and hasattr(table, "sort_by_title"):
|
||||
table.rows.sort(
|
||||
key=lambda row: _column_sort_key(row.get_column("Tag"), numeric=numeric),
|
||||
reverse=bool(reverse),
|
||||
)
|
||||
return
|
||||
|
||||
table.rows.sort(
|
||||
key=lambda row: _column_sort_key(row.get_column(column_name), numeric=numeric),
|
||||
reverse=bool(reverse),
|
||||
)
|
||||
|
||||
|
||||
def _reorder_items_from_table(table: Any, items: List[Any]) -> List[Any]:
|
||||
if not items or table is None or not hasattr(table, "rows"):
|
||||
return list(items or [])
|
||||
|
||||
payloads: List[Any] = []
|
||||
for row in getattr(table, "rows", []) or []:
|
||||
payload = getattr(row, "payload", None)
|
||||
if payload is None:
|
||||
payloads = []
|
||||
break
|
||||
payloads.append(payload)
|
||||
if payloads and len(payloads) == len(getattr(table, "rows", []) or []):
|
||||
return payloads
|
||||
|
||||
reordered: List[Any] = []
|
||||
for row in getattr(table, "rows", []) or []:
|
||||
source_index = getattr(row, "source_index", None)
|
||||
if isinstance(source_index, int) and 0 <= source_index < len(items):
|
||||
reordered.append(items[source_index])
|
||||
|
||||
if reordered and len(reordered) == len(getattr(table, "rows", []) or []):
|
||||
return reordered
|
||||
return list(items or [])
|
||||
|
||||
|
||||
def _render_table(table: Any) -> int:
|
||||
if table is None:
|
||||
log("No active result table", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
if hasattr(table, "to_rich"):
|
||||
stdout_console().print(table.to_rich())
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log(f"Failed to render table: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
print(table)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log(f"Failed to print table: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _sanitize_filename_base(text: str) -> str:
|
||||
s = str(text or "").strip()
|
||||
if not s:
|
||||
return "table"
|
||||
|
||||
s = _ILLEGAL_FILENAME_CHARS_RE.sub(" ", s)
|
||||
s = "".join(ch for ch in s if ch.isprintable())
|
||||
s = " ".join(s.split()).strip()
|
||||
s = s.rstrip(" .")
|
||||
|
||||
if not s:
|
||||
s = "table"
|
||||
if s.lower() in _WINDOWS_RESERVED_NAMES:
|
||||
s = f"_{s}"
|
||||
if len(s) > 200:
|
||||
s = s[:200].rstrip(" .")
|
||||
return s or "table"
|
||||
|
||||
|
||||
def _resolve_output_path(path_arg: str, *, table_title: str) -> Path:
|
||||
raw = str(path_arg or "").strip()
|
||||
if not raw:
|
||||
raise ValueError("-path is required")
|
||||
|
||||
ends_with_sep = raw.endswith(("/", "\\"))
|
||||
target = Path(raw)
|
||||
|
||||
if target.exists() and target.is_dir():
|
||||
return target / f"{_sanitize_filename_base(table_title)}.svg"
|
||||
|
||||
if (ends_with_sep or not target.suffix) and not target.exists():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
return target / f"{_sanitize_filename_base(table_title)}.svg"
|
||||
|
||||
if not target.suffix:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
return target.with_suffix(".svg")
|
||||
if target.suffix.lower() != ".svg":
|
||||
return target.with_suffix(".svg")
|
||||
return target
|
||||
|
||||
|
||||
def _export_table_svg(table: Any, path_arg: str) -> int:
|
||||
if table is None:
|
||||
log("No table available to export", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
title_text = str(getattr(table, "title", None) or "table")
|
||||
|
||||
try:
|
||||
out_path = _resolve_output_path(path_arg, table_title=title_text)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
console = Console(record=True)
|
||||
renderable = table.to_rich() if hasattr(table, "to_rich") else table
|
||||
console.print(renderable)
|
||||
console.save_svg(str(out_path))
|
||||
log(f"Saved table SVG: {out_path}")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
log(f"Failed to save table SVG: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _apply_table_sort(table: Any, *, sort_column: str, query_text: str) -> int:
|
||||
query_fields = _parse_table_query(query_text)
|
||||
wanted_column = str(sort_column or query_fields.get("sort") or "").strip()
|
||||
namespace = str(query_fields.get("namespace") or "").strip().rstrip(":")
|
||||
order = str(query_fields.get("format") or query_fields.get("order") or "asc").strip().lower()
|
||||
reverse = order in {"desc", "descending", "reverse", "z-a"}
|
||||
|
||||
numeric_field = query_fields.get("numeric")
|
||||
if numeric_field is not None:
|
||||
numeric = _normalize_bool(numeric_field)
|
||||
else:
|
||||
numeric = namespace.casefold() in _NUMERIC_NAMESPACE_HINTS
|
||||
|
||||
if not wanted_column and namespace:
|
||||
wanted_column = "tag"
|
||||
if not wanted_column:
|
||||
wanted_column = "title"
|
||||
|
||||
try:
|
||||
if str(wanted_column).strip().lower() == "tag" and namespace:
|
||||
if not hasattr(table, "sort_by_tag_namespace"):
|
||||
log("Current table does not support namespace sorting", file=sys.stderr)
|
||||
return 1
|
||||
table.sort_by_tag_namespace(namespace, numeric=numeric, reverse=reverse)
|
||||
else:
|
||||
_sort_by_column(table, wanted_column, numeric=numeric, reverse=reverse)
|
||||
except Exception as exc:
|
||||
log(f"Failed to sort table: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if hasattr(table, "_perseverance"):
|
||||
try:
|
||||
table._perseverance(True)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _run(piped_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
# Debug utility: dump current pipeline table state (display/current/last + buffers)
|
||||
_ = piped_result, config
|
||||
|
||||
try:
|
||||
from SYS import pipeline as ctx
|
||||
except Exception as exc:
|
||||
log(f"Failed to import pipeline context: {exc}")
|
||||
return 1
|
||||
|
||||
parsed = parse_cmdlet_args(args, CMDLET)
|
||||
sort_column = str(parsed.get("sort") or "").strip()
|
||||
query_text = str(parsed.get("query") or "").strip()
|
||||
debug_mode = bool(parsed.get("debug", False))
|
||||
print_mode = bool(parsed.get("print", False))
|
||||
path_arg = str(parsed.get("path") or "").strip()
|
||||
|
||||
active_table, _table_kind = _active_table_bundle(ctx)
|
||||
|
||||
if print_mode or path_arg:
|
||||
if not path_arg:
|
||||
log("Missing required -path for table export", file=sys.stderr)
|
||||
return 1
|
||||
return _export_table_svg(active_table, path_arg)
|
||||
|
||||
if not debug_mode and not sort_column and not query_text:
|
||||
return _render_table(active_table)
|
||||
|
||||
if not debug_mode and (sort_column or query_text):
|
||||
base_table = active_table
|
||||
if base_table is None:
|
||||
log("No active result table to sort", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
working_table = _clone_table(base_table)
|
||||
rc = _apply_table_sort(working_table, sort_column=sort_column, query_text=query_text)
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
items = list(ctx.get_last_result_items() or [])
|
||||
reordered_items = _reorder_items_from_table(working_table, items)
|
||||
subject = ctx.get_last_result_subject() if hasattr(ctx, "get_last_result_subject") else None
|
||||
ctx.set_last_result_table_overlay(working_table, reordered_items, subject)
|
||||
ctx.set_current_stage_table(working_table)
|
||||
return _render_table(working_table)
|
||||
|
||||
state = None
|
||||
try:
|
||||
state = ctx.get_pipeline_state() if hasattr(ctx, "get_pipeline_state") else None
|
||||
@@ -108,13 +426,39 @@ def _run(piped_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
|
||||
CMDLET = Cmdlet(
|
||||
name=".table",
|
||||
summary="Dump pipeline table state for debugging",
|
||||
usage=".table [label]",
|
||||
alias=["table"],
|
||||
summary="Render, inspect, or sort the active result table.",
|
||||
usage='.table [-sort <column>] [-query "format:asc|desc,namespace:track"] [-print -path <path>] [-debug [label]]',
|
||||
arg=[
|
||||
CmdletArg(
|
||||
name="sort",
|
||||
type="string",
|
||||
description="Sort by a visible column name (for namespace tag sorting, use -sort tag with -query namespace:<name>).",
|
||||
required=False,
|
||||
),
|
||||
CmdletArg(
|
||||
name="query",
|
||||
type="string",
|
||||
description="Table options like format:asc|desc, namespace:track, numeric:true.",
|
||||
required=False,
|
||||
),
|
||||
CmdletArg(
|
||||
name="print",
|
||||
type="flag",
|
||||
description="Export the active table as an SVG using -path.",
|
||||
required=False,
|
||||
),
|
||||
SharedArgs.PATH,
|
||||
CmdletArg(
|
||||
name="debug",
|
||||
type="flag",
|
||||
description="Dump pipeline table state for debugging instead of rendering the table.",
|
||||
required=False,
|
||||
),
|
||||
CmdletArg(
|
||||
name="label",
|
||||
type="string",
|
||||
description="Optional label to include in the dump",
|
||||
description="Optional label to include in the debug dump",
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -18,8 +18,8 @@ UOSC defines its own right-click menu, and there's no straightforward way to ove
|
||||
3. **Right-click** - Attempts to trigger via input.conf, but UOSC overrides (needs investigation)
|
||||
|
||||
### How It Works
|
||||
- `MPV/portable_config/input.conf` routes keybindings to Lua handlers
|
||||
- `MPV/LUA/main.lua`:
|
||||
- `plugins/mpv/portable_config/input.conf` routes keybindings to Lua handlers
|
||||
- `plugins/mpv/LUA/main.lua`:
|
||||
- Registers script message handler: `medios-show-menu`
|
||||
- Registers Lua keybindings for 'm' and 'z' keys
|
||||
- Both route to `M.show_menu()` which opens an UOSC menu with items
|
||||
@@ -35,25 +35,25 @@ The menu calls UOSC's `open-menu` handler with JSON containing:
|
||||
- Start Helper (if not running)
|
||||
|
||||
## Files Modified
|
||||
- `MPV/LUA/main.lua`:
|
||||
- `plugins/mpv/LUA/main.lua`:
|
||||
- Fixed Lua syntax error (extra `end)`)
|
||||
- Added comprehensive `[MENU]` and `[KEY]` logging
|
||||
- Added 'm' and 'z' keybindings
|
||||
- Added `medios-show-menu` script message handler
|
||||
- Enhanced `M.show_menu()` with dual methods to call UOSC
|
||||
|
||||
- `MPV/portable_config/input.conf`:
|
||||
- `plugins/mpv/portable_config/input.conf`:
|
||||
- Routes `mbtn_right` to `script-message medios-show-menu`
|
||||
- Routes 'm' key to `script-message medios-show-menu`
|
||||
|
||||
- `MPV/portable_config/mpv.conf`:
|
||||
- `plugins/mpv/portable_config/mpv.conf`:
|
||||
- Fixed `audio-display` setting (was invalid `yes`, now `no`)
|
||||
|
||||
## Testing
|
||||
Run: `python test_menu.py`
|
||||
|
||||
Or manually:
|
||||
1. Start MPV with: `mpv --script=MPV/LUA/main.lua --config-dir=MPV/portable_config --idle`
|
||||
1. Start MPV with: `mpv --script=plugins/mpv/LUA/main.lua --config-dir=plugins/mpv/portable_config --idle`
|
||||
2. Press 'm' or 'z' key
|
||||
3. Check logs at `Log/medeia-mpv-lua.log` for `[MENU]` entries
|
||||
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
# Tag Template Syntax
|
||||
|
||||
This guide documents the reusable template syntax for tag mutation commands such as `add-tag` and `delete-tag`.
|
||||
|
||||
The current goal is lowercase-first tagging. Examples in this document use lowercase tag names and lowercase text values, and no case-conversion transforms are part of the documented syntax.
|
||||
|
||||
## Where It Works
|
||||
|
||||
The shared template resolver currently applies to:
|
||||
|
||||
- `add-tag`
|
||||
- `delete-tag`
|
||||
|
||||
Templates are resolved per item against that item's current tag set and lightweight result fields such as the current title.
|
||||
|
||||
## Core Placeholder Syntax
|
||||
|
||||
Use `#(namespace)` to insert the value from an existing namespaced tag.
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
add-tag "title:#(track) - #(series)"
|
||||
add-tag "album:#(series)"
|
||||
delete-tag "title:#(track) - #(series)"
|
||||
```
|
||||
|
||||
If an item has:
|
||||
|
||||
```text
|
||||
track:9
|
||||
series:ancient greek intensive course
|
||||
```
|
||||
|
||||
then:
|
||||
|
||||
```text
|
||||
title:#(track) - #(series)
|
||||
```
|
||||
|
||||
resolves to:
|
||||
|
||||
```text
|
||||
title:9 - ancient greek intensive course
|
||||
```
|
||||
|
||||
## Namespace Matching
|
||||
|
||||
- Namespace matching is case-insensitive.
|
||||
- Repeated whitespace inside the placeholder is normalized.
|
||||
- A trailing `#` is ignored for compatibility, so `#(track #)` resolves the same way as `#(track)`.
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
add-tag "title:#(track #) - #(series)"
|
||||
add-tag "code:#(disc number)"
|
||||
```
|
||||
|
||||
## Transform Syntax
|
||||
|
||||
Use angle brackets for transforms:
|
||||
|
||||
```text
|
||||
<name(arg1,arg2,...)>
|
||||
```
|
||||
|
||||
Transforms run after `#(namespace)` placeholders are expanded.
|
||||
|
||||
### Padding
|
||||
|
||||
Use `padding`, `pad`, or `zfill` to zero-pad a value.
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
add-tag "code:e<padding(00,#(episode))>"
|
||||
add-tag "code:e<pad(2,#(episode))>"
|
||||
add-tag "code:e<zfill(2,#(episode))>"
|
||||
```
|
||||
|
||||
If `episode:3` exists, each example resolves to:
|
||||
|
||||
```text
|
||||
code:e03
|
||||
```
|
||||
|
||||
Padding width can be written in either of these forms:
|
||||
|
||||
- `00` meaning width 2
|
||||
- `000` meaning width 3
|
||||
- `2` meaning width 2
|
||||
- `3` meaning width 3
|
||||
|
||||
### Default
|
||||
|
||||
Use `default(value,fallback)` when a namespace may be missing.
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
add-tag "season:<default(#(season),0)>"
|
||||
add-tag "disc:<default(#(disc),1)>"
|
||||
```
|
||||
|
||||
If `season:` is missing, the first example resolves to:
|
||||
|
||||
```text
|
||||
season:0
|
||||
```
|
||||
|
||||
### Replace
|
||||
|
||||
Use `replace(value,old,new)` for simple substring replacement.
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
add-tag "slug:<replace(#(title),' ',_)>"
|
||||
add-tag "slug:<replace(#(series),-,_)>"
|
||||
```
|
||||
|
||||
If `title:ancient greek intensive course` exists, the first example resolves to:
|
||||
|
||||
```text
|
||||
slug:ancient_greek_intensive_course
|
||||
```
|
||||
|
||||
Quote a space when you want to replace literal spaces. Bare spaces are trimmed by argument parsing, so `' '` is the reliable form.
|
||||
|
||||
### Increment
|
||||
|
||||
Use `increment(value,amount)` to do small integer adjustments.
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
add-tag "episode_next:<increment(#(episode),1)>"
|
||||
add-tag "disc_next:<increment(#(disc),1)>"
|
||||
```
|
||||
|
||||
If `episode:3` exists, the first example resolves to:
|
||||
|
||||
```text
|
||||
episode_next:4
|
||||
```
|
||||
|
||||
The second argument is optional; `<increment(#(episode))>` also adds `1`.
|
||||
|
||||
## Commas Inside Transforms
|
||||
|
||||
Tag arguments still support comma-separated tags, but commas inside transform calls are preserved.
|
||||
|
||||
This means the following stays as two tags, not three fragments:
|
||||
|
||||
```powershell
|
||||
add-tag "code:e<padding(00,#(episode))>,title:#(series)"
|
||||
```
|
||||
|
||||
## Combining With `-extract`
|
||||
|
||||
Templates are especially useful after deriving tags from a title.
|
||||
|
||||
Example:
|
||||
|
||||
```powershell
|
||||
add-tag -extract "(series) - part (track)" "title:#(track) - #(series)"
|
||||
```
|
||||
|
||||
For a title like:
|
||||
|
||||
```text
|
||||
ancient greek intensive course - part 9
|
||||
```
|
||||
|
||||
this can derive:
|
||||
|
||||
```text
|
||||
series:ancient greek intensive course
|
||||
track:9
|
||||
title:9 - ancient greek intensive course
|
||||
```
|
||||
|
||||
## Missing Values
|
||||
|
||||
If a placeholder or transform cannot be resolved, the whole templated tag is skipped instead of being written literally.
|
||||
|
||||
Examples of skipped cases:
|
||||
|
||||
- `title:#(missing_namespace)` when no such tag exists
|
||||
- `code:<padding(x,#(episode))>` when the padding width is invalid
|
||||
|
||||
The command logs a warning summary for skipped unresolved templates.
|
||||
|
||||
## Recommended Patterns
|
||||
|
||||
Episode-style numbering:
|
||||
|
||||
```powershell
|
||||
add-tag "code:e<padding(00,#(episode))>"
|
||||
```
|
||||
|
||||
Title synthesis from extracted tags:
|
||||
|
||||
```powershell
|
||||
add-tag -extract "(series) - part (track)" "title:#(track) - #(series)"
|
||||
```
|
||||
|
||||
Delete a derived title tag:
|
||||
|
||||
```powershell
|
||||
delete-tag "title:#(track) - #(series)"
|
||||
```
|
||||
|
||||
Reuse an existing value under a new namespace:
|
||||
|
||||
```powershell
|
||||
add-tag "album:#(series)"
|
||||
```
|
||||
|
||||
## Mass Tagging Recipes
|
||||
|
||||
These are the patterns most likely to be useful when cleaning or normalizing large existing tag sets.
|
||||
|
||||
### Build A Stable Episode Code
|
||||
|
||||
If items already have `episode:` values and you want a compact sortable code:
|
||||
|
||||
```powershell
|
||||
add-tag "code:e<padding(00,#(episode))>"
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
episode:3 -> code:e03
|
||||
episode:12 -> code:e12
|
||||
```
|
||||
|
||||
### Build Season-Episode Style Labels
|
||||
|
||||
If you already carry both season and episode values:
|
||||
|
||||
```powershell
|
||||
add-tag "code:s<padding(00,#(season))>e<padding(00,#(episode))>"
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
season:1
|
||||
episode:3
|
||||
```
|
||||
|
||||
becomes:
|
||||
|
||||
```text
|
||||
code:s01e03
|
||||
```
|
||||
|
||||
### Fill Missing Season Values Before Building A Code
|
||||
|
||||
If some items have episodes but no season tag yet:
|
||||
|
||||
```powershell
|
||||
add-tag "season:<default(#(season),0)>" "code:s<padding(00,#(season))>e<padding(00,#(episode))>"
|
||||
```
|
||||
|
||||
That lets a later code template stay predictable even when the source metadata is incomplete.
|
||||
|
||||
### Rebuild A Title From Existing Tags
|
||||
|
||||
If you have normalized tags but want a cleaner `title:`:
|
||||
|
||||
```powershell
|
||||
add-tag "title:#(series) - #(track)"
|
||||
```
|
||||
|
||||
or for episode-style material:
|
||||
|
||||
```powershell
|
||||
add-tag "title:#(series) e<padding(00,#(episode))>"
|
||||
```
|
||||
|
||||
### Extract Then Reformat In One Pass
|
||||
|
||||
If the current title is messy but predictable:
|
||||
|
||||
```powershell
|
||||
add-tag -extract "(series) - part (track)" "title:#(track) - #(series)"
|
||||
```
|
||||
|
||||
This is useful when you want to convert display-oriented titles into searchable structured tags and then immediately synthesize a cleaner title back from them.
|
||||
|
||||
### Promote Existing Values Into New Namespaces
|
||||
|
||||
If one namespace already has the correct normalized value and you want to reuse it elsewhere:
|
||||
|
||||
```powershell
|
||||
add-tag "album:#(series)"
|
||||
add-tag "label:#(publisher)"
|
||||
add-tag "subtitle:#(title)"
|
||||
```
|
||||
|
||||
### Create URL-Safe Or Filename-Safe Slugs
|
||||
|
||||
If you want a simple underscore slug from an existing title:
|
||||
|
||||
```powershell
|
||||
add-tag "slug:<replace(#(title),' ',_)>"
|
||||
```
|
||||
|
||||
For more involved slug cleanup, chain multiple commands over time by writing intermediate normalized tags instead of expecting one giant expression.
|
||||
|
||||
### Create "Next Episode" Or Offset Tags
|
||||
|
||||
If you need a helper value for ordering or automation:
|
||||
|
||||
```powershell
|
||||
add-tag "episode_next:<increment(#(episode),1)>"
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```powershell
|
||||
add-tag "episode_prev:<increment(#(episode),-1)>"
|
||||
```
|
||||
|
||||
### Delete A Derived Tag Predictably
|
||||
|
||||
Once a tag was created from a template, you can remove it with the same template:
|
||||
|
||||
```powershell
|
||||
delete-tag "title:#(track) - #(series)"
|
||||
delete-tag "code:s<padding(00,#(season))>e<padding(00,#(episode))>"
|
||||
```
|
||||
|
||||
This is safer than manually typing the fully expanded value when doing bulk cleanup.
|
||||
|
||||
### Keep Inputs Lowercase Upstream
|
||||
|
||||
Because the documented system is lowercase-first, the cleanest workflows normalize source tags before using them in templates.
|
||||
|
||||
Recommended pattern:
|
||||
|
||||
- keep namespace names lowercase
|
||||
- keep values lowercase when you create/import them
|
||||
- use templates to compose values, not to fix letter casing later
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
series:ancient greek intensive course
|
||||
episode:3
|
||||
publisher:oxford
|
||||
```
|
||||
|
||||
compose more predictably than mixed-case sources.
|
||||
|
||||
## Current Supported Syntax Summary
|
||||
|
||||
- `#(namespace)` inserts an existing tag value
|
||||
- `#(track #)` compatibility aliasing works for namespaces that include a trailing `#`
|
||||
- `<padding(width,value)>` zero-pads values
|
||||
- `<pad(width,value)>` is an alias of `padding`
|
||||
- `<zfill(width,value)>` is an alias of `padding`
|
||||
- `<default(value,fallback)>` uses a fallback when the primary value is missing
|
||||
- `<replace(value,old,new)>` performs plain substring replacement
|
||||
- `<increment(value,amount)>` adds an integer offset, defaulting to `1`
|
||||
|
||||
If more transforms are added later, they should follow the same angle-bracket function style rather than introducing a second expression format.
|
||||
+1
-1
@@ -57,4 +57,4 @@ Bundled walkthrough:
|
||||
- The walkthrough is in [docs/ftp_plugin_tutorial.md](docs/ftp_plugin_tutorial.md) and shows `search-file -plugin ftp -instance <name>`, folder drill-in via `@N`, file download routing, `@N | add-file -store ...`, and `add-file -plugin ftp -instance <name>` uploads.
|
||||
- The repo also includes an SCP example plugin in [plugins/scp/__init__.py](plugins/scp/__init__.py).
|
||||
- The walkthrough is in [docs/scp_plugin_tutorial.md](docs/scp_plugin_tutorial.md) and shows `search-file -plugin scp -instance <name>`, SSH-backed directory drill-in, file download routing, `@N | add-file -store ...`, and `add-file -plugin scp -instance <name>` uploads.
|
||||
- The repo now also includes a built-in HydrusNetwork provider in [plugins/hydrusnetwork/__init__.py](plugins/hydrusnetwork/__init__.py). Its Hydrus client API now lives alongside it in [plugins/hydrusnetwork/api.py](plugins/hydrusnetwork/api.py), while [API/HydrusNetwork.py](API/HydrusNetwork.py) remains a compatibility shim. The provider delegates to configured `store.hydrusnetwork.*` backends so Hydrus features can be reached through the normal plugin registry without cmdlets importing Hydrus modules directly.
|
||||
- The repo now also includes a built-in HydrusNetwork provider in [plugins/hydrusnetwork/__init__.py](plugins/hydrusnetwork/__init__.py). Its Hydrus client API now lives in the plugin-owned package [plugins/hydrusnetwork/api/__init__.py](plugins/hydrusnetwork/api/__init__.py), its registry-facing store adapter lives in [plugins/hydrusnetwork/store_proxy.py](plugins/hydrusnetwork/store_proxy.py), and its heavy internal operations live in [plugins/hydrusnetwork/store_backend.py](plugins/hydrusnetwork/store_backend.py). This `plugins/<name>/api/` package shape is the intended pattern for plugin-owned API helpers going forward. The provider now resolves configured Hydrus instances directly from plugin config instead of routing back through `Store.registry`; the proxy exists only so generic store callers can still target configured Hydrus stores. [API/HydrusNetwork.py](API/HydrusNetwork.py) and [Store/HydrusNetwork.py](Store/HydrusNetwork.py) are legacy compatibility shims only, and store discovery prefers the plugin-owned Hydrus hook over those shims.
|
||||
@@ -12,7 +12,7 @@ from typing import Any, Dict, Iterable, List, Optional, Callable, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from API.HTTP import HTTPClient, _download_direct_file
|
||||
from API.alldebrid import AllDebridClient, parse_magnet_or_hash, is_torrent_file
|
||||
from plugins.alldebrid.api import AllDebridClient, parse_magnet_or_hash, is_torrent_file
|
||||
from ProviderCore.base import Provider, SearchResult
|
||||
from SYS.provider_helpers import TableProviderMixin
|
||||
from SYS.item_accessors import get_field as _extract_value
|
||||
@@ -859,7 +859,7 @@ class AllDebrid(TableProviderMixin, Provider):
|
||||
return None
|
||||
|
||||
try:
|
||||
from API.alldebrid import AllDebridClient
|
||||
from plugins.alldebrid.api import AllDebridClient
|
||||
|
||||
client = AllDebridClient(api_key)
|
||||
except Exception as exc:
|
||||
@@ -1400,7 +1400,7 @@ class AllDebrid(TableProviderMixin, Provider):
|
||||
view = view or "folders"
|
||||
|
||||
try:
|
||||
from API.alldebrid import AllDebridClient
|
||||
from plugins.alldebrid.api import AllDebridClient
|
||||
|
||||
client = AllDebridClient(api_key)
|
||||
except Exception as exc:
|
||||
|
||||
@@ -15,7 +15,7 @@ from typing import Any, Dict, Optional, Set, List, Sequence, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from SYS.logger import log, debug
|
||||
from .HTTP import HTTPClient
|
||||
from API.HTTP import HTTPClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from API.Tidal import (
|
||||
from plugins.tidal.api import (
|
||||
Tidal as TidalApiClient,
|
||||
build_track_tags,
|
||||
coerce_duration_seconds,
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from API.loc import LOCClient
|
||||
from plugins.loc.api import LOCClient
|
||||
from ProviderCore.base import Provider, SearchResult
|
||||
from SYS.cli_syntax import get_free_text, parse_query
|
||||
from SYS.logger import log
|
||||
|
||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .base import API, ApiError
|
||||
from API.base import API, ApiError
|
||||
|
||||
|
||||
class LOCError(ApiError):
|
||||
@@ -14,15 +14,15 @@ from SYS.logger import log, debug
|
||||
from SYS.result_table import Table
|
||||
from SYS.item_accessors import get_sha256_hex
|
||||
from SYS.utils import extract_hydrus_hash_from_url
|
||||
from SYS import pipeline as ctx
|
||||
from ProviderCore.registry import get_plugin, get_plugin_for_url
|
||||
from cmdnat._parsing import (
|
||||
from SYS.command_parsing import (
|
||||
extract_arg_value,
|
||||
extract_piped_value as _extract_piped_value,
|
||||
extract_value_arg as _extract_value_arg,
|
||||
has_flag as _has_flag,
|
||||
normalize_to_list as _normalize_to_list,
|
||||
)
|
||||
from SYS import pipeline as ctx
|
||||
from ProviderCore.registry import get_plugin, get_plugin_for_url
|
||||
|
||||
_MATRIX_PENDING_ITEMS_KEY = "matrix_pending_items"
|
||||
_MATRIX_PENDING_TEXT_KEY = "matrix_pending_text"
|
||||
@@ -1297,3 +1297,5 @@ CMDLET = Cmdlet(
|
||||
],
|
||||
exec=_run,
|
||||
)
|
||||
|
||||
COMMANDS = [CMDLET]
|
||||
@@ -15,7 +15,7 @@ try:
|
||||
from plugins.tidal import Tidal
|
||||
except ImportError: # pragma: no cover - optional
|
||||
Tidal = None
|
||||
from API.Tidal import (
|
||||
from plugins.tidal.api import (
|
||||
build_track_tags,
|
||||
extract_artists,
|
||||
stringify,
|
||||
|
||||
@@ -396,6 +396,8 @@ function M._sync_uosc_cursor(reason)
|
||||
if ensure_uosc_loaded() then
|
||||
pcall(mp.commandv, 'script-message-to', 'uosc', 'sync-cursor')
|
||||
end
|
||||
M._disable_input_section('input_uosc', why .. suffix)
|
||||
M._disable_input_section('input_forced_uosc', why .. suffix)
|
||||
M._disable_input_section('input_console', why .. suffix)
|
||||
M._disable_input_section('input_forced_console', why .. suffix)
|
||||
M._disable_input_section('image', why .. suffix)
|
||||
@@ -410,6 +412,19 @@ function M._sync_uosc_cursor(reason)
|
||||
end)
|
||||
end
|
||||
|
||||
function M._schedule_uosc_cursor_resync(reason)
|
||||
local why = tostring(reason or 'unknown')
|
||||
local delays = { 0.05, 0.20, 0.60 }
|
||||
for _, delay in ipairs(delays) do
|
||||
mp.add_timeout(delay, function()
|
||||
local video_info = mp.get_property_native('current-tracks/video')
|
||||
if not (type(video_info) == 'table' and video_info.image == true) then
|
||||
M._sync_uosc_cursor(why .. '@' .. tostring(delay))
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
function M._close_uosc_menu_and_sync(menu_type, reason)
|
||||
local why = tostring(reason or 'unknown')
|
||||
if ensure_uosc_loaded() then
|
||||
@@ -425,6 +440,8 @@ end
|
||||
|
||||
M._reset_uosc_input_state = function(reason)
|
||||
local why = tostring(reason or 'unknown')
|
||||
M._disable_input_section('input_uosc', why)
|
||||
M._disable_input_section('input_forced_uosc', why)
|
||||
M._disable_input_section('input_console', why)
|
||||
M._disable_input_section('input_forced_console', why)
|
||||
M._disable_input_section('image', why)
|
||||
@@ -529,7 +546,7 @@ local function trim(s)
|
||||
end
|
||||
|
||||
-- Lyrics overlay toggle
|
||||
-- The Python helper (python -m MPV.lyric) will read this property via IPC.
|
||||
-- The Python helper (python -m plugins.mpv.lyric) will read this property via IPC.
|
||||
local LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible"
|
||||
|
||||
local function lyric_get_visible()
|
||||
@@ -679,7 +696,11 @@ end
|
||||
local function _detect_format_probe_script()
|
||||
local repo_root = _detect_repo_root()
|
||||
if repo_root ~= '' then
|
||||
local direct = utils.join_path(repo_root, 'MPV/format_probe.py')
|
||||
local direct = utils.join_path(repo_root, 'plugins/mpv/format_probe.py')
|
||||
if _path_exists(direct) then
|
||||
return direct
|
||||
end
|
||||
direct = utils.join_path(repo_root, 'MPV/format_probe.py')
|
||||
if _path_exists(direct) then
|
||||
return direct
|
||||
end
|
||||
@@ -690,6 +711,9 @@ local function _detect_format_probe_script()
|
||||
local source_dir = _get_lua_source_path():match('(.*)[/\\]') or ''
|
||||
local script_dir = mp.get_script_directory() or ''
|
||||
local cwd = utils.getcwd() or ''
|
||||
_append_unique_path(candidates, seen, find_file_upwards(source_dir, 'plugins/mpv/format_probe.py', 8))
|
||||
_append_unique_path(candidates, seen, find_file_upwards(script_dir, 'plugins/mpv/format_probe.py', 8))
|
||||
_append_unique_path(candidates, seen, find_file_upwards(cwd, 'plugins/mpv/format_probe.py', 8))
|
||||
_append_unique_path(candidates, seen, find_file_upwards(source_dir, 'MPV/format_probe.py', 8))
|
||||
_append_unique_path(candidates, seen, find_file_upwards(script_dir, 'MPV/format_probe.py', 8))
|
||||
_append_unique_path(candidates, seen, find_file_upwards(cwd, 'MPV/format_probe.py', 8))
|
||||
@@ -771,10 +795,12 @@ local function _build_sibling_script_candidates(file_name)
|
||||
_append_unique_path(candidates, seen, script_dir .. '/' .. file_name)
|
||||
_append_unique_path(candidates, seen, script_dir .. '/LUA/' .. file_name)
|
||||
_append_unique_path(candidates, seen, script_dir .. '/../' .. file_name)
|
||||
_append_unique_path(candidates, seen, find_file_upwards(script_dir, 'plugins/mpv/LUA/' .. file_name, 8))
|
||||
_append_unique_path(candidates, seen, find_file_upwards(script_dir, 'MPV/LUA/' .. file_name, 8))
|
||||
end
|
||||
|
||||
if cwd ~= '' then
|
||||
_append_unique_path(candidates, seen, find_file_upwards(cwd, 'plugins/mpv/LUA/' .. file_name, 8))
|
||||
_append_unique_path(candidates, seen, find_file_upwards(cwd, 'MPV/LUA/' .. file_name, 8))
|
||||
end
|
||||
|
||||
@@ -1412,10 +1438,16 @@ local function attempt_start_pipeline_helper_async(callback)
|
||||
local helper_script = ''
|
||||
local repo_root = _detect_repo_root()
|
||||
if repo_root ~= '' then
|
||||
local direct = utils.join_path(repo_root, 'MPV/pipeline_helper.py')
|
||||
local direct = utils.join_path(repo_root, 'plugins/mpv/pipeline_helper.py')
|
||||
if _path_exists(direct) then
|
||||
helper_script = direct
|
||||
end
|
||||
if helper_script == '' then
|
||||
direct = utils.join_path(repo_root, 'MPV/pipeline_helper.py')
|
||||
if _path_exists(direct) then
|
||||
helper_script = direct
|
||||
end
|
||||
end
|
||||
end
|
||||
if helper_script == '' then
|
||||
local candidates = {}
|
||||
@@ -1423,6 +1455,9 @@ local function attempt_start_pipeline_helper_async(callback)
|
||||
local source_dir = _get_lua_source_path():match('(.*)[/\\]') or ''
|
||||
local script_dir = mp.get_script_directory() or ''
|
||||
local cwd = utils.getcwd() or ''
|
||||
_append_unique_path(candidates, seen, find_file_upwards(source_dir, 'plugins/mpv/pipeline_helper.py', 8))
|
||||
_append_unique_path(candidates, seen, find_file_upwards(script_dir, 'plugins/mpv/pipeline_helper.py', 8))
|
||||
_append_unique_path(candidates, seen, find_file_upwards(cwd, 'plugins/mpv/pipeline_helper.py', 8))
|
||||
_append_unique_path(candidates, seen, find_file_upwards(source_dir, 'MPV/pipeline_helper.py', 8))
|
||||
_append_unique_path(candidates, seen, find_file_upwards(script_dir, 'MPV/pipeline_helper.py', 8))
|
||||
_append_unique_path(candidates, seen, find_file_upwards(cwd, 'MPV/pipeline_helper.py', 8))
|
||||
@@ -1441,7 +1476,7 @@ local function attempt_start_pipeline_helper_async(callback)
|
||||
|
||||
local launch_root = repo_root
|
||||
if launch_root == '' then
|
||||
launch_root = helper_script:match('(.*)[/\\]MPV[/\\]') or (helper_script:match('(.*)[/\\]') or '')
|
||||
launch_root = helper_script:match('(.*)[/\\]plugins[/\\]mpv[/\\]') or helper_script:match('(.*)[/\\]MPV[/\\]') or (helper_script:match('(.*)[/\\]') or '')
|
||||
end
|
||||
|
||||
local bootstrap = table.concat({
|
||||
@@ -1528,7 +1563,7 @@ function M._resolve_repo_script(relative_path)
|
||||
|
||||
for _, candidate in ipairs(candidates) do
|
||||
if _path_exists(candidate) then
|
||||
local launch_root = candidate:match('(.*)[/\\]MPV[/\\]') or (candidate:match('(.*)[/\\]') or '')
|
||||
local launch_root = candidate:match('(.*)[/\\]plugins[/\\]mpv[/\\]') or candidate:match('(.*)[/\\]MPV[/\\]') or (candidate:match('(.*)[/\\]') or '')
|
||||
return candidate, launch_root
|
||||
end
|
||||
end
|
||||
@@ -1561,9 +1596,12 @@ function M._attempt_start_lyric_helper_async(reason)
|
||||
return false
|
||||
end
|
||||
|
||||
local lyric_script, launch_root = M._resolve_repo_script('MPV/lyric.py')
|
||||
local lyric_script, launch_root = M._resolve_repo_script('plugins/mpv/lyric.py')
|
||||
if lyric_script == '' then
|
||||
_lua_log('lyric-helper: MPV/lyric.py not found reason=' .. tostring(reason))
|
||||
lyric_script, launch_root = M._resolve_repo_script('MPV/lyric.py')
|
||||
end
|
||||
if lyric_script == '' then
|
||||
_lua_log('lyric-helper: lyric.py not found reason=' .. tostring(reason))
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -2852,9 +2890,7 @@ local function _commit_pending_screenshot(tags)
|
||||
end
|
||||
|
||||
local function _apply_screenshot_tag_query(query)
|
||||
pcall(function()
|
||||
mp.commandv('script-message-to', 'uosc', 'close-menu', SCREENSHOT_TAG_MENU_TYPE)
|
||||
end)
|
||||
M._close_uosc_menu_and_sync(SCREENSHOT_TAG_MENU_TYPE, 'screenshot-tags-submit')
|
||||
_commit_pending_screenshot(_normalize_tag_list(query))
|
||||
end
|
||||
|
||||
@@ -2888,7 +2924,9 @@ local function _open_screenshot_tag_prompt(store, out_path)
|
||||
},
|
||||
}
|
||||
|
||||
mp.commandv('script-message-to', 'uosc', 'open-menu', utils.format_json(menu_data))
|
||||
if not M._open_uosc_menu(menu_data, 'screenshot-tag-prompt') then
|
||||
_commit_pending_screenshot(nil)
|
||||
end
|
||||
end
|
||||
|
||||
local function _open_store_picker_for_pending_screenshot()
|
||||
@@ -3320,8 +3358,8 @@ local function _open_sleep_timer_prompt()
|
||||
items = items,
|
||||
}
|
||||
|
||||
if ensure_uosc_loaded() then
|
||||
mp.commandv('script-message-to', 'uosc', 'open-menu', utils.format_json(menu_data))
|
||||
if M._open_uosc_menu(menu_data, 'sleep-timer-prompt') then
|
||||
return
|
||||
else
|
||||
mp.osd_message('Sleep timer unavailable (uosc not loaded)', 2.0)
|
||||
end
|
||||
@@ -3336,9 +3374,7 @@ local function _apply_sleep_timer_query(query)
|
||||
|
||||
if minutes <= 0 then
|
||||
_cancel_sleep_timer(true)
|
||||
pcall(function()
|
||||
mp.commandv('script-message-to', 'uosc', 'close-menu', SLEEP_PROMPT_MENU_TYPE)
|
||||
end)
|
||||
M._close_uosc_menu_and_sync(SLEEP_PROMPT_MENU_TYPE, 'sleep-timer-cancel')
|
||||
return
|
||||
end
|
||||
|
||||
@@ -3354,9 +3390,7 @@ local function _apply_sleep_timer_query(query)
|
||||
mp.osd_message(string.format('Sleep timer set: %d min', math.floor(minutes + 0.5)), 1.5)
|
||||
_lua_log('sleep: timer set minutes=' .. tostring(minutes) .. ' seconds=' .. tostring(seconds))
|
||||
|
||||
pcall(function()
|
||||
mp.commandv('script-message-to', 'uosc', 'close-menu', SLEEP_PROMPT_MENU_TYPE)
|
||||
end)
|
||||
M._close_uosc_menu_and_sync(SLEEP_PROMPT_MENU_TYPE, 'sleep-timer-submit')
|
||||
end
|
||||
|
||||
local function _handle_sleep_timer_event(json)
|
||||
@@ -3445,6 +3479,7 @@ end
|
||||
local function _deactivate_image_controls()
|
||||
if not ImageControl.enabled then
|
||||
_disable_image_section()
|
||||
M._schedule_uosc_cursor_resync('image-controls-disabled-idle')
|
||||
return
|
||||
end
|
||||
ImageControl.enabled = false
|
||||
@@ -3459,6 +3494,7 @@ local function _deactivate_image_controls()
|
||||
mp.set_property_number('video-pan-y', 0)
|
||||
mp.set_property('video-align-x', '0')
|
||||
mp.set_property('video-align-y', '0')
|
||||
M._schedule_uosc_cursor_resync('image-controls-disabled')
|
||||
end
|
||||
|
||||
local function _update_image_mode()
|
||||
@@ -3472,6 +3508,9 @@ end
|
||||
|
||||
mp.register_event('file-loaded', function()
|
||||
_update_image_mode()
|
||||
if not _get_current_item_is_image() then
|
||||
M._schedule_uosc_cursor_resync('file-loaded')
|
||||
end
|
||||
end)
|
||||
|
||||
mp.register_event('shutdown', function()
|
||||
@@ -5709,6 +5748,24 @@ mp.observe_property('track-list', 'native', function()
|
||||
M._ensure_current_subtitles_visible('observe-track-list')
|
||||
end)
|
||||
|
||||
mp.observe_property('window-minimized', 'bool', function(_name, value)
|
||||
if value == true then
|
||||
_lua_log('window: minimized -> reset uosc input state')
|
||||
M._reset_uosc_input_state('window-minimized')
|
||||
return
|
||||
end
|
||||
|
||||
_lua_log('window: restored -> reset uosc input state')
|
||||
M._reset_uosc_input_state('window-restored')
|
||||
M._schedule_uosc_cursor_resync('window-restored')
|
||||
mp.add_timeout(0.10, function()
|
||||
M._reset_uosc_input_state('window-restored@0.10')
|
||||
end)
|
||||
mp.add_timeout(0.35, function()
|
||||
M._schedule_uosc_cursor_resync('window-restored@0.35')
|
||||
end)
|
||||
end)
|
||||
|
||||
mp.observe_property('ytdl-raw-info', 'native', function(_name, value)
|
||||
if type(value) ~= 'table' then
|
||||
return
|
||||
@@ -0,0 +1,5 @@
|
||||
from plugins.mpv.mpv_ipc import MPV
|
||||
|
||||
__all__ = [
|
||||
"MPV",
|
||||
]
|
||||
@@ -10,10 +10,10 @@ from datetime import datetime, timedelta
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from pathlib import Path
|
||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg, parse_cmdlet_args
|
||||
from ProviderCore.registry import get_plugin, get_plugin_for_url
|
||||
from ProviderCore.registry import get_plugin, get_plugin_for_url, list_plugin_names_with_capability
|
||||
from SYS.logger import debug, get_thread_stream, is_debug_enabled, set_debug, set_thread_stream
|
||||
from SYS.result_table import Table
|
||||
from MPV.mpv_ipc import MPV
|
||||
from plugins.mpv.mpv_ipc import MPV
|
||||
from SYS import pipeline as ctx
|
||||
from SYS.models import PipeObject
|
||||
|
||||
@@ -32,13 +32,6 @@ _IPV4_RE = re.compile(r"^\d+\.\d+\.\d+\.\d+$")
|
||||
_MPD_PATH_RE = re.compile(r"\.mpd($|\?)")
|
||||
|
||||
|
||||
def _get_hydrus_provider(config: Optional[Dict[str, Any]] = None) -> Any:
|
||||
try:
|
||||
return get_plugin("hydrusnetwork", config or {})
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
try:
|
||||
return Path(__file__).resolve().parent.parent
|
||||
@@ -564,6 +557,111 @@ def _resolve_plugin_playback_path(item: Any, config: Optional[Dict[str, Any]]) -
|
||||
return None
|
||||
|
||||
|
||||
def _iter_provider_hook_candidates(
|
||||
capability: str,
|
||||
*,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
targets: Optional[Sequence[str]] = None,
|
||||
) -> List[Any]:
|
||||
providers: List[Any] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for target in targets or ():
|
||||
try:
|
||||
provider = get_plugin_for_url(str(target or ""), config or {})
|
||||
except Exception:
|
||||
provider = None
|
||||
if provider is None:
|
||||
continue
|
||||
name = str(getattr(provider, "name", "") or "").strip().lower()
|
||||
if name and name not in seen:
|
||||
seen.add(name)
|
||||
providers.append(provider)
|
||||
|
||||
try:
|
||||
provider_names = list_plugin_names_with_capability(capability)
|
||||
except Exception:
|
||||
provider_names = []
|
||||
|
||||
for provider_name in provider_names:
|
||||
try:
|
||||
provider = get_plugin(provider_name, config or {})
|
||||
except Exception:
|
||||
provider = None
|
||||
if provider is None:
|
||||
continue
|
||||
name = str(getattr(provider, "name", provider_name) or provider_name).strip().lower()
|
||||
if name and name not in seen:
|
||||
seen.add(name)
|
||||
providers.append(provider)
|
||||
|
||||
return providers
|
||||
|
||||
|
||||
def _resolve_provider_item_context(
|
||||
item: Any,
|
||||
*,
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
store: Optional[str],
|
||||
file_hash: Optional[str],
|
||||
targets: Sequence[str],
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
resolved_store = store
|
||||
resolved_hash = file_hash
|
||||
|
||||
for provider in _iter_provider_hook_candidates(
|
||||
"pipe-item-context",
|
||||
config=config,
|
||||
targets=targets,
|
||||
):
|
||||
try:
|
||||
result = provider.resolve_pipe_item_context(
|
||||
item,
|
||||
metadata=metadata,
|
||||
store=resolved_store,
|
||||
file_hash=resolved_hash,
|
||||
targets=targets,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if not result or not isinstance(result, tuple) or len(result) != 2:
|
||||
continue
|
||||
next_store, next_hash = result
|
||||
if next_store:
|
||||
resolved_store = str(next_store).strip()
|
||||
if next_hash:
|
||||
resolved_hash = str(next_hash).strip().lower()
|
||||
|
||||
return resolved_store, resolved_hash
|
||||
|
||||
|
||||
def _infer_provider_playlist_store(
|
||||
item: Any,
|
||||
*,
|
||||
target: str,
|
||||
file_storage: Any = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[str]:
|
||||
for provider in _iter_provider_hook_candidates(
|
||||
"playlist-store",
|
||||
config=config,
|
||||
targets=[target],
|
||||
):
|
||||
try:
|
||||
resolved = provider.infer_playlist_store(
|
||||
item,
|
||||
target=target,
|
||||
file_storage=file_storage,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
text = str(resolved or "").strip()
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_lyric_overlay(mpv: MPV) -> None:
|
||||
try:
|
||||
mpv.ensure_lyric_loader_running()
|
||||
@@ -638,29 +736,17 @@ def _extract_store_and_hash(
|
||||
except Exception:
|
||||
file_hash = None
|
||||
|
||||
hydrus_provider = _get_hydrus_provider(config)
|
||||
if hydrus_provider is not None:
|
||||
normalized_store = None
|
||||
try:
|
||||
if store and hydrus_provider.is_store_name(store):
|
||||
normalized_store = store
|
||||
except Exception:
|
||||
normalized_store = None
|
||||
store, file_hash = _resolve_provider_item_context(
|
||||
item,
|
||||
metadata=metadata if isinstance(metadata, dict) else None,
|
||||
store=store,
|
||||
file_hash=file_hash,
|
||||
targets=targets,
|
||||
config=config,
|
||||
)
|
||||
|
||||
for target in targets:
|
||||
try:
|
||||
parsed_store, parsed_hash = hydrus_provider.parse_hydrus_url(target)
|
||||
except Exception:
|
||||
parsed_store, parsed_hash = None, ""
|
||||
if parsed_hash and not file_hash:
|
||||
file_hash = parsed_hash
|
||||
if parsed_store:
|
||||
normalized_store = parsed_store
|
||||
|
||||
if normalized_store:
|
||||
store = normalized_store
|
||||
elif store and store.upper() in {"PATH", "LOCAL", "UNKNOWN"}:
|
||||
store = None
|
||||
if store and store.upper() in {"PATH", "LOCAL", "UNKNOWN"}:
|
||||
store = None
|
||||
|
||||
if not file_hash:
|
||||
try:
|
||||
@@ -725,7 +811,7 @@ def _prefetch_notes_async(
|
||||
|
||||
def _worker() -> None:
|
||||
try:
|
||||
from MPV.lyric import (
|
||||
from plugins.mpv.lyric import (
|
||||
load_cached_notes,
|
||||
set_notes_prefetch_pending,
|
||||
store_cached_notes,
|
||||
@@ -754,7 +840,7 @@ def _prefetch_notes_async(
|
||||
debug(f"MPV note prefetch failed for {key}: {exc}", file=sys.stderr)
|
||||
finally:
|
||||
try:
|
||||
from MPV.lyric import set_notes_prefetch_pending
|
||||
from plugins.mpv.lyric import set_notes_prefetch_pending
|
||||
|
||||
set_notes_prefetch_pending(store, file_hash, False)
|
||||
except Exception:
|
||||
@@ -849,62 +935,6 @@ def _extract_target_from_memory_uri(text: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _find_hydrus_instance_for_hash(
|
||||
hash_str: str,
|
||||
file_storage: Any,
|
||||
*,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[str]:
|
||||
"""Find which Hydrus instance serves a specific file hash.
|
||||
|
||||
Args:
|
||||
hash_str: SHA256 hash (64 hex chars)
|
||||
file_storage: FileStorage instance with Hydrus backends
|
||||
|
||||
Returns:
|
||||
Instance name (e.g., 'home') or None if not found
|
||||
"""
|
||||
hydrus_provider = _get_hydrus_provider(config)
|
||||
if hydrus_provider is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
for backend_name, _backend in hydrus_provider.iter_backends():
|
||||
try:
|
||||
if hydrus_provider.hash_exists(hash_str, store_name=str(backend_name)):
|
||||
return str(backend_name)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _find_hydrus_instance_by_url(
|
||||
url: str,
|
||||
file_storage: Any,
|
||||
*,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[str]:
|
||||
"""Find which Hydrus instance matches a given URL.
|
||||
|
||||
Args:
|
||||
url: Full URL (e.g., http://localhost:45869/get_files/file?hash=...)
|
||||
file_storage: FileStorage instance with Hydrus backends
|
||||
|
||||
Returns:
|
||||
Instance name (e.g., 'home') or None if not found
|
||||
"""
|
||||
hydrus_provider = _get_hydrus_provider(config)
|
||||
if hydrus_provider is None:
|
||||
return None
|
||||
try:
|
||||
return hydrus_provider.match_store_name_for_url(url)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_playlist_path(text: Optional[str]) -> Optional[str]:
|
||||
"""Normalize playlist entry paths for dedupe comparisons."""
|
||||
if not text:
|
||||
@@ -960,29 +990,18 @@ def _infer_store_from_playlist_item(
|
||||
if memory_target:
|
||||
target = memory_target
|
||||
|
||||
# Hydrus hashes: bare 64-hex entries
|
||||
if _SHA256_FULL_RE.fullmatch(target.lower()):
|
||||
# If we have file_storage, query each Hydrus instance to find which one has this hash
|
||||
if file_storage:
|
||||
hash_str = target.lower()
|
||||
hydrus_instance = _find_hydrus_instance_for_hash(hash_str, file_storage, config=config)
|
||||
if hydrus_instance:
|
||||
return hydrus_instance
|
||||
return "hydrus"
|
||||
provider_store = _infer_provider_playlist_store(
|
||||
item,
|
||||
target=target,
|
||||
file_storage=file_storage,
|
||||
config=config,
|
||||
)
|
||||
if provider_store:
|
||||
return provider_store
|
||||
|
||||
lower = target.lower()
|
||||
if lower.startswith("magnet:"):
|
||||
return "magnet"
|
||||
if lower.startswith("hydrus://"):
|
||||
# Extract hash from hydrus:// URL if possible
|
||||
if file_storage:
|
||||
hash_match = _SHA256_RE.search(target.lower())
|
||||
if hash_match:
|
||||
hash_str = hash_match.group(0)
|
||||
hydrus_instance = _find_hydrus_instance_for_hash(hash_str, file_storage, config=config)
|
||||
if hydrus_instance:
|
||||
return hydrus_instance
|
||||
return "hydrus"
|
||||
|
||||
# Windows / UNC paths
|
||||
if _WINDOWS_PATH_RE.match(target) or target.startswith("\\\\"):
|
||||
@@ -1010,35 +1029,6 @@ def _infer_store_from_playlist_item(
|
||||
return "soundcloud"
|
||||
if "bandcamp" in host_stripped:
|
||||
return "bandcamp"
|
||||
if "get_files" in path or "file?hash=" in path or host_stripped in {"127.0.0.1",
|
||||
"localhost"}:
|
||||
# Hydrus API URL - try to extract hash and find instance
|
||||
if file_storage:
|
||||
# Try to extract hash from URL parameters
|
||||
hash_match = _HASH_QUERY_RE.search(target.lower())
|
||||
if hash_match:
|
||||
hash_str = hash_match.group(1)
|
||||
hydrus_instance = _find_hydrus_instance_for_hash(hash_str, file_storage, config=config)
|
||||
if hydrus_instance:
|
||||
return hydrus_instance
|
||||
# If no hash in URL, try matching the base URL to configured instances
|
||||
hydrus_instance = _find_hydrus_instance_by_url(target, file_storage, config=config)
|
||||
if hydrus_instance:
|
||||
return hydrus_instance
|
||||
return "hydrus"
|
||||
if _IPV4_RE.match(host_stripped) and "get_files" in path:
|
||||
# IP-based Hydrus URL
|
||||
if file_storage:
|
||||
hash_match = _HASH_QUERY_RE.search(target.lower())
|
||||
if hash_match:
|
||||
hash_str = hash_match.group(1)
|
||||
hydrus_instance = _find_hydrus_instance_for_hash(hash_str, file_storage, config=config)
|
||||
if hydrus_instance:
|
||||
return hydrus_instance
|
||||
hydrus_instance = _find_hydrus_instance_by_url(target, file_storage, config=config)
|
||||
if hydrus_instance:
|
||||
return hydrus_instance
|
||||
return "hydrus"
|
||||
|
||||
parts = host_stripped.split(".")
|
||||
if len(parts) >= 2:
|
||||
@@ -1444,29 +1434,27 @@ def _get_playable_path(
|
||||
# - MPV IPC pipe (transport)
|
||||
# - PipeObject (pipeline data)
|
||||
backend_target_resolved = False
|
||||
hydrus_provider = _get_hydrus_provider(config)
|
||||
if store and file_hash and file_hash != "unknown" and file_storage:
|
||||
if store and file_hash and file_hash != "unknown":
|
||||
try:
|
||||
backend = file_storage[store]
|
||||
except Exception:
|
||||
backend = None
|
||||
resolved_path = _resolve_plugin_playback_path(
|
||||
{
|
||||
"store": str(store),
|
||||
"hash": str(file_hash),
|
||||
"path": path,
|
||||
"url": path,
|
||||
},
|
||||
config,
|
||||
)
|
||||
except Exception as e:
|
||||
debug(
|
||||
f"Error resolving playback path from store '{store}': {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
resolved_path = None
|
||||
|
||||
if backend is not None:
|
||||
if resolved_path:
|
||||
backend_target_resolved = True
|
||||
|
||||
# Hydrus playback should resolve via the provider so store aliases and URL building stay centralized.
|
||||
if hydrus_provider is not None and hydrus_provider.is_backend(backend, str(store)):
|
||||
try:
|
||||
resolved_path = hydrus_provider.build_file_url(file_hash, store_name=str(store))
|
||||
if resolved_path:
|
||||
path = resolved_path
|
||||
except Exception as e:
|
||||
debug(
|
||||
f"Error building Hydrus URL from store '{store}': {e}",
|
||||
file=sys.stderr
|
||||
)
|
||||
else:
|
||||
backend_target_resolved = False
|
||||
path = resolved_path
|
||||
|
||||
if isinstance(path, str) and path.startswith(("http://", "https://")) and not backend_target_resolved:
|
||||
return (path, title)
|
||||
@@ -1735,7 +1723,7 @@ def _queue_items(
|
||||
"request_id":
|
||||
199,
|
||||
}
|
||||
_send_ipc_command(header_cmd, silent=True, wait=False)
|
||||
_send_ipc_command(header_cmd, silent=True, wait=True)
|
||||
if effective_ytdl_opts:
|
||||
ytdl_cmd = {
|
||||
"command":
|
||||
@@ -1744,7 +1732,7 @@ def _queue_items(
|
||||
effective_ytdl_opts],
|
||||
"request_id": 197,
|
||||
}
|
||||
_send_ipc_command(ytdl_cmd, silent=True, wait=False)
|
||||
_send_ipc_command(ytdl_cmd, silent=True, wait=True)
|
||||
|
||||
# For memory:// M3U payloads (used to carry titles), use loadlist so mpv parses
|
||||
# the content as a playlist and does not expose #EXTINF lines as entries.
|
||||
@@ -2227,16 +2215,16 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
) if isinstance(playlist_after,
|
||||
list) else 0
|
||||
|
||||
should_autoplay = False
|
||||
if idle_before is True:
|
||||
should_autoplay = True
|
||||
elif isinstance(playlist_before,
|
||||
list) and len(playlist_before) == 0:
|
||||
should_autoplay = True
|
||||
|
||||
if should_autoplay and after_len > 0:
|
||||
idx_to_play: Optional[int] = None
|
||||
if after_len > before_len:
|
||||
idx_to_play = min(max(0, before_len), after_len - 1)
|
||||
elif idle_before is True and after_len > 0:
|
||||
idx_to_play = after_len - 1
|
||||
elif isinstance(playlist_before,
|
||||
list) and len(playlist_before) == 0 and after_len > 0:
|
||||
idx_to_play = after_len - 1
|
||||
|
||||
if idx_to_play is not None:
|
||||
# Prefer the store/hash from the piped item when auto-playing.
|
||||
try:
|
||||
s, h = _extract_store_and_hash(items_to_add[0], config=config)
|
||||
@@ -2276,6 +2264,12 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
items = _get_playlist(silent=True)
|
||||
|
||||
if items is None:
|
||||
mpv_process_alive = False
|
||||
try:
|
||||
mpv_process_alive = MPV().has_process_owner()
|
||||
except Exception:
|
||||
mpv_process_alive = False
|
||||
|
||||
if mpv_started:
|
||||
# MPV was just started, retry getting playlist after a brief delay
|
||||
import time
|
||||
@@ -2288,6 +2282,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
debug("MPV is starting up...")
|
||||
return 0
|
||||
else:
|
||||
if mpv_process_alive:
|
||||
debug("MPV is already running, but the Windows IPC pipe is busy. Not starting a second instance.")
|
||||
return 0
|
||||
|
||||
# Do not auto-launch MPV when no action/inputs were provided; avoid surprise startups
|
||||
no_inputs = not any(
|
||||
[
|
||||
@@ -2548,6 +2546,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||
helper_status = "not running"
|
||||
if helper_heartbeat not in (None, "", "0", False):
|
||||
helper_status = f"running ({helper_heartbeat})"
|
||||
else:
|
||||
try:
|
||||
mpv_live = MPV()
|
||||
if mpv_live.has_process_owner():
|
||||
helper_status = "ipc busy or helper-owned connection"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(f"Pipeline helper: {helper_status}")
|
||||
|
||||
@@ -2823,3 +2828,5 @@ CMDLET = Cmdlet(
|
||||
],
|
||||
exec=_run,
|
||||
)
|
||||
|
||||
COMMANDS = [CMDLET]
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
def _repo_root() -> Path:
|
||||
package_dir = Path(__file__).resolve().parent
|
||||
if package_dir.name.lower() == "mpv" and package_dir.parent.name.lower() == "plugins":
|
||||
return package_dir.parent.parent
|
||||
return package_dir.parent
|
||||
|
||||
|
||||
REPO_ROOT = _repo_root()
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if not args:
|
||||
payload: Dict[str, Any] = {
|
||||
"success": False,
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"error": "Missing url",
|
||||
"table": None,
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
return 2
|
||||
|
||||
url = str(args[0] or "").strip()
|
||||
captured_stdout = io.StringIO()
|
||||
captured_stderr = io.StringIO()
|
||||
with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr):
|
||||
from plugins.mpv.pipeline_helper import _run_op
|
||||
|
||||
payload = _run_op("ytdlp-formats", {"url": url})
|
||||
|
||||
noisy_stdout = captured_stdout.getvalue().strip()
|
||||
noisy_stderr = captured_stderr.getvalue().strip()
|
||||
if noisy_stdout:
|
||||
payload["stdout"] = "\n".join(filter(None, [str(payload.get("stdout") or "").strip(), noisy_stdout]))
|
||||
if noisy_stderr:
|
||||
payload["stderr"] = "\n".join(filter(None, [str(payload.get("stderr") or "").strip(), noisy_stderr]))
|
||||
|
||||
print(json.dumps(payload, ensure_ascii=False))
|
||||
return 0 if payload.get("success") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ audio-buffer=2.0
|
||||
|
||||
# Ensure uosc texture/icon fonts are discoverable by libass.
|
||||
osd-fonts-dir=~~/scripts/uosc/fonts
|
||||
sub-fonts-dir=~~/scripts/uosc/
|
||||
sub-fonts-dir=~~/scripts/uosc/fonts
|
||||
|
||||
ontop=yes
|
||||
autofit=45%
|
||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -193,7 +193,7 @@ class PodcastIndex(Provider):
|
||||
feed_url = str(feed_md.get("url") or item0.get("path") or "").strip()
|
||||
|
||||
try:
|
||||
from API.podcastindex import PodcastIndexClient
|
||||
from plugins.podcastindex.api import PodcastIndexClient
|
||||
|
||||
client = PodcastIndexClient(key, secret)
|
||||
if feed_id:
|
||||
@@ -407,7 +407,7 @@ class PodcastIndex(Provider):
|
||||
return []
|
||||
|
||||
try:
|
||||
from API.podcastindex import PodcastIndexClient
|
||||
from plugins.podcastindex.api import PodcastIndexClient
|
||||
|
||||
client = PodcastIndexClient(key, secret)
|
||||
feeds = client.search_byterm(query, max_results=limit)
|
||||
|
||||
@@ -15,7 +15,7 @@ import hashlib
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import API, ApiError
|
||||
from API.base import API, ApiError
|
||||
|
||||
|
||||
class PodcastIndexError(ApiError):
|
||||
@@ -5,11 +5,11 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg
|
||||
from SYS.command_parsing import has_flag as _has_flag, normalize_to_list as _normalize_to_list
|
||||
from SYS.logger import log
|
||||
from SYS.result_table import Table
|
||||
from SYS import pipeline as ctx
|
||||
from ProviderCore.registry import get_plugin
|
||||
from cmdnat._parsing import has_flag as _has_flag, normalize_to_list as _normalize_to_list
|
||||
|
||||
_TELEGRAM_PENDING_ITEMS_KEY = "telegram_pending_items"
|
||||
|
||||
@@ -339,3 +339,5 @@ CMDLET = Cmdlet(
|
||||
],
|
||||
exec=_run,
|
||||
)
|
||||
|
||||
COMMANDS = [CMDLET]
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from API.Tidal import (
|
||||
from plugins.tidal.api import (
|
||||
Tidal as TidalApiClient,
|
||||
build_track_tags,
|
||||
coerce_duration_seconds,
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from .base import API, ApiError
|
||||
from API.base import API, ApiError
|
||||
from SYS.logger import debug, debug_panel
|
||||
|
||||
DEFAULT_BASE_URL = "https://tidal-api.binimum.org"
|
||||
@@ -268,14 +268,14 @@ class Tidal(API):
|
||||
|
||||
# 1. Fetch info (metadata) - fetch raw to ensure all fields are available for merging
|
||||
info_resp = self._get_json("info/", params={"id": track_int})
|
||||
_debug_payload_summary("API.Tidal info", info_resp)
|
||||
_debug_payload_summary("plugins.tidal.api info", info_resp)
|
||||
info_data = info_resp.get("data") if isinstance(info_resp, dict) else info_resp
|
||||
if not isinstance(info_data, dict) or "id" not in info_data:
|
||||
info_data = info_resp if isinstance(info_resp, dict) and "id" in info_resp else {}
|
||||
|
||||
# 2. Fetch track (manifest/bit depth)
|
||||
track_resp = self.track(track_id)
|
||||
_debug_payload_summary("API.Tidal track", track_resp)
|
||||
_debug_payload_summary("plugins.tidal.api track", track_resp)
|
||||
# Note: track() method in this class currently returns raw JSON, so we handle it similarly.
|
||||
track_data = track_resp.get("data") if isinstance(track_resp, dict) else track_resp
|
||||
if not isinstance(track_data, dict):
|
||||
@@ -285,7 +285,7 @@ class Tidal(API):
|
||||
lyrics_data = {}
|
||||
try:
|
||||
lyr_resp = self.lyrics(track_id)
|
||||
_debug_payload_summary("API.Tidal lyrics", lyr_resp)
|
||||
_debug_payload_summary("plugins.tidal.api lyrics", lyr_resp)
|
||||
lyrics_data = lyr_resp.get("lyrics") or lyr_resp if isinstance(lyr_resp, dict) else {}
|
||||
except Exception:
|
||||
pass
|
||||
@@ -309,7 +309,7 @@ class Tidal(API):
|
||||
"lyrics": lyrics_data,
|
||||
}
|
||||
debug_panel(
|
||||
"API.Tidal full track metadata",
|
||||
"plugins.tidal.api full track metadata",
|
||||
[
|
||||
("track_id", track_int),
|
||||
("metadata_keys", len(merged_md)),
|
||||
@@ -20,6 +20,7 @@ Medios-Macina is a API driven file media manager and virtual toolbox capable of
|
||||
<h2>CONTENTS</H2>
|
||||
<a href="#features">FEATURES</a><br>
|
||||
<a href="#installation">INSTALLATION</a><br>
|
||||
<a href="docs/tag_template_syntax.md">TAG TEMPLATE SYNTAX</a><br>
|
||||
<a href="https://code.glowers.club/goyimnose/Medios-Macina/wiki/Config.conf">CONFIG</a><br>
|
||||
<a href="https://code.glowers.club/goyimnose/Medios-Macina/wiki/Hydrus-Network">HYDRUS NETWORK</a><br>
|
||||
<a href="https://code.glowers.club/goyimnose/Medios-Macina/wiki/cookies.txt">COOKIES</a><br>
|
||||
@@ -38,6 +39,7 @@ Medios-Macina is a API driven file media manager and virtual toolbox capable of
|
||||
<li><b>Optional stacks:</b> Telethon (Telegram), aioslsk (Soulseek), and the FlorenceVision tooling install automatically when you configure the corresponding plugin/tool blocks.
|
||||
<li><b>MPV Manager:</b> Play audio, video, and even images in a custom designed MPV with trimming, screenshotting, and more built right in!</li>
|
||||
<li><i>Supports remote access and networked setups for offsite servers and sharing workflows.</i></li>
|
||||
<li><b>Reusable tag templates:</b> derive new tags from existing ones with placeholder and padding syntax documented in <a href="docs/tag_template_syntax.md">docs/tag_template_syntax.md</a>.</li>
|
||||
</ul>
|
||||
</ul
|
||||
|
||||
|
||||
@@ -1057,6 +1057,7 @@ def main() -> int:
|
||||
def _ensure_repo_available() -> bool:
|
||||
"""Prompt for a clone location when running outside the repository."""
|
||||
nonlocal repo_root, script_dir, is_in_repo
|
||||
repo_dir_name = "Medios-Macina"
|
||||
|
||||
# If we have already settled on a repository path in this session, skip.
|
||||
if is_in_repo and repo_root is not None:
|
||||
@@ -1099,6 +1100,14 @@ def main() -> int:
|
||||
print("Error: Could not determine installation path.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
if install_path.exists() and install_path.is_dir() and not _is_valid_mm_repo(install_path):
|
||||
try:
|
||||
has_contents = any(install_path.iterdir())
|
||||
except Exception:
|
||||
has_contents = False
|
||||
if has_contents:
|
||||
install_path = install_path / repo_dir_name
|
||||
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
|
||||
|
||||
Reference in New Issue
Block a user