huge refactor of plugin system

This commit is contained in:
2026-04-30 18:56:22 -07:00
parent ea3ead248b
commit be5a11da97
99 changed files with 7603 additions and 11320 deletions
-2730
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -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
View File
@@ -1,5 +1,3 @@
from MPV.mpv_ipc import MPV
from plugins.mpv import MPV
__all__ = [
"MPV",
]
__all__ = ["MPV"]
+3 -45
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1172
View File
File diff suppressed because it is too large Load Diff
+3 -2157
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -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.
"""
-8
View File
@@ -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
-8
View File
@@ -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
-8
View File
@@ -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
View File
@@ -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."""
+99
View File
@@ -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
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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}")
+79
View File
@@ -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]
-8
View File
@@ -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.
+4 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+28
View File
@@ -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
View File
@@ -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:")
+4 -4
View File
@@ -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
View File
@@ -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)
-165
View File
@@ -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
View File
@@ -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
View File
@@ -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,
),
],
+6 -6
View File
@@ -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
+371
View File
@@ -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
View File
@@ -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.
+3 -3
View File
@@ -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__)
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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,
+76 -19
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
from plugins.mpv.mpv_ipc import MPV
__all__ = [
"MPV",
]
+174 -167
View File
@@ -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]
+55
View File
@@ -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())
+2036
View File
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

+2 -2
View File
@@ -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]
+1 -1
View File
@@ -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)),
+2
View File
@@ -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
+9
View File
@@ -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