huge refactor of plugin system
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -131,7 +131,7 @@ def _send_mpv_ipc_command(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from MPV.mpv_ipc import MPVIPCClient, get_ipc_pipe_path
|
from plugins.mpv.mpv_ipc import MPVIPCClient, get_ipc_pipe_path
|
||||||
|
|
||||||
client = MPVIPCClient(
|
client = MPVIPCClient(
|
||||||
socket_path=str(ipc_path or get_ipc_pipe_path()),
|
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:
|
||||||
try:
|
try:
|
||||||
from MPV.mpv_ipc import MPV
|
from plugins.mpv.mpv_ipc import MPV
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
MPV()
|
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"):
|
if _has_store_subtype(config, "debrid"):
|
||||||
try:
|
try:
|
||||||
from SYS.config import get_debrid_api_key
|
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)
|
api_key = get_debrid_api_key(config)
|
||||||
if not api_key:
|
if not api_key:
|
||||||
|
|||||||
+2
-4
@@ -1,5 +1,3 @@
|
|||||||
from MPV.mpv_ipc import MPV
|
from plugins.mpv import MPV
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["MPV"]
|
||||||
"MPV",
|
|
||||||
]
|
|
||||||
+3
-45
@@ -1,48 +1,6 @@
|
|||||||
from __future__ import annotations
|
from plugins.mpv.format_probe import *
|
||||||
|
from plugins.mpv.format_probe import main as _main
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(_main())
|
||||||
+3
-2020
File diff suppressed because it is too large
Load Diff
+1
-1172
File diff suppressed because it is too large
Load Diff
+3
-2157
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
"""Legacy compatibility package.
|
|
||||||
|
|
||||||
Bundled runtime plugins now live under ``plugins/``. This package remains only
|
|
||||||
for helper modules and backwards-compatible imports that have not been removed
|
|
||||||
yet.
|
|
||||||
"""
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"""Legacy compatibility shim for the strict adapter example module.
|
|
||||||
|
|
||||||
The active implementation now lives in ``plugins.example_provider`` so the
|
|
||||||
plugin namespace owns the example adapter module. Keep this file only to avoid
|
|
||||||
breaking old imports while the legacy ``Provider`` package is phased out.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from plugins.example_provider import * # noqa: F401,F403
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"""Legacy compatibility shim for metadata helpers.
|
|
||||||
|
|
||||||
The active implementation now lives in ``plugins.metadata_provider`` so the
|
|
||||||
plugin namespace owns runtime metadata scraping. Keep this file only to avoid
|
|
||||||
breaking old imports while the legacy ``Provider`` package is phased out.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from plugins.metadata_provider import * # noqa: F401,F403
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
"""Legacy compatibility shim for Tidal manifest helpers.
|
|
||||||
|
|
||||||
The active implementation now lives in ``plugins.tidal_manifest`` so the
|
|
||||||
plugin namespace owns the manifest helper module. Keep this file only to avoid
|
|
||||||
breaking old imports while the legacy ``Provider`` package is phased out.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from plugins.tidal_manifest import * # noqa: F401,F403
|
|
||||||
+46
-7
@@ -30,6 +30,7 @@ class SearchResult:
|
|||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert to dictionary for pipeline processing."""
|
"""Convert to dictionary for pipeline processing."""
|
||||||
|
full_metadata = self.full_metadata if isinstance(self.full_metadata, dict) else {}
|
||||||
out = {
|
out = {
|
||||||
"table": self.table,
|
"table": self.table,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
@@ -40,15 +41,29 @@ class SearchResult:
|
|||||||
"size_bytes": self.size_bytes,
|
"size_bytes": self.size_bytes,
|
||||||
"tag": list(self.tag),
|
"tag": list(self.tag),
|
||||||
"columns": list(self.columns),
|
"columns": list(self.columns),
|
||||||
"full_metadata": self.full_metadata,
|
"full_metadata": full_metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
for key in (
|
||||||
url_value = getattr(self, "url", None)
|
"url",
|
||||||
if url_value is not None:
|
"hash",
|
||||||
out["url"] = url_value
|
"hash_hex",
|
||||||
except Exception:
|
"store",
|
||||||
pass
|
"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:
|
try:
|
||||||
selection_args = getattr(self, "selection_args", None)
|
selection_args = getattr(self, "selection_args", None)
|
||||||
@@ -195,6 +210,30 @@ class Provider(ABC):
|
|||||||
"""
|
"""
|
||||||
return "search-file", list(args_list)
|
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
|
@property
|
||||||
def prefers_transfer_progress(self) -> bool:
|
def prefers_transfer_progress(self) -> bool:
|
||||||
"""True if this plugin prefers explicit transfer progress tracking (begin/finish) during download."""
|
"""True if this plugin prefers explicit transfer progress tracking (begin/finish) during download."""
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Dict, Iterable, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
CmdletFn = Callable[[Any, Sequence[str], Dict[str, Any]], int]
|
||||||
|
|
||||||
|
|
||||||
|
def iter_command_objects(module: Any) -> list[Any]:
|
||||||
|
objects: list[Any] = []
|
||||||
|
|
||||||
|
many = getattr(module, "COMMANDS", None)
|
||||||
|
if isinstance(many, (list, tuple)):
|
||||||
|
for item in many:
|
||||||
|
if item is not None:
|
||||||
|
objects.append(item)
|
||||||
|
|
||||||
|
single = getattr(module, "COMMAND", None)
|
||||||
|
if single is not None:
|
||||||
|
objects.append(single)
|
||||||
|
|
||||||
|
legacy = getattr(module, "CMDLET", None)
|
||||||
|
if legacy is not None:
|
||||||
|
objects.append(legacy)
|
||||||
|
|
||||||
|
deduped: list[Any] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for item in objects:
|
||||||
|
marker = id(item)
|
||||||
|
if marker in seen:
|
||||||
|
continue
|
||||||
|
seen.add(marker)
|
||||||
|
deduped.append(item)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def get_primary_command_object(module: Any) -> Any:
|
||||||
|
commands = iter_command_objects(module)
|
||||||
|
return commands[0] if commands else None
|
||||||
|
|
||||||
|
|
||||||
|
def _register_command_object(cmdlet_obj: Any, registry: Dict[str, CmdletFn]) -> None:
|
||||||
|
run_fn = getattr(cmdlet_obj, "exec", None) if hasattr(cmdlet_obj, "exec") else None
|
||||||
|
if not callable(run_fn):
|
||||||
|
return
|
||||||
|
|
||||||
|
name = getattr(cmdlet_obj, "name", None)
|
||||||
|
if name:
|
||||||
|
registry[str(name).replace("_", "-").lower()] = run_fn
|
||||||
|
|
||||||
|
aliases: list[str] = []
|
||||||
|
if hasattr(cmdlet_obj, "alias") and getattr(cmdlet_obj, "alias"):
|
||||||
|
aliases.extend(getattr(cmdlet_obj, "alias") or [])
|
||||||
|
if hasattr(cmdlet_obj, "aliases") and getattr(cmdlet_obj, "aliases"):
|
||||||
|
aliases.extend(getattr(cmdlet_obj, "aliases") or [])
|
||||||
|
|
||||||
|
for alias in aliases:
|
||||||
|
text = str(alias or "").strip()
|
||||||
|
if text:
|
||||||
|
registry[text.replace("_", "-").lower()] = run_fn
|
||||||
|
|
||||||
|
|
||||||
|
def iter_plugin_command_module_names() -> list[str]:
|
||||||
|
try:
|
||||||
|
repo_root = Path(__file__).resolve().parent.parent
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
plugins_dir = repo_root / "plugins"
|
||||||
|
if not plugins_dir.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
|
module_names: list[str] = []
|
||||||
|
for entry in sorted(plugins_dir.iterdir(), key=lambda path: path.name.lower()):
|
||||||
|
if not entry.is_dir() or entry.name.startswith("."):
|
||||||
|
continue
|
||||||
|
if not (entry / "__init__.py").is_file():
|
||||||
|
continue
|
||||||
|
if (entry / "commands.py").is_file() or (entry / "commands" / "__init__.py").is_file():
|
||||||
|
module_names.append(f"plugins.{entry.name}.commands")
|
||||||
|
return module_names
|
||||||
|
|
||||||
|
|
||||||
|
def register_plugin_commands(registry: Dict[str, CmdletFn]) -> None:
|
||||||
|
for module_name in iter_plugin_command_module_names():
|
||||||
|
try:
|
||||||
|
module = import_module(module_name)
|
||||||
|
for cmdlet_obj in iter_command_objects(module):
|
||||||
|
_register_command_object(cmdlet_obj, registry)
|
||||||
|
except Exception as exc:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Error importing plugin command '{module_name}': {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
@@ -505,6 +505,18 @@ def _supports_capability(provider: Provider, capability: str) -> bool:
|
|||||||
return _supports_search(provider)
|
return _supports_search(provider)
|
||||||
if capability_key in {"upload", "file", "file-provider"}:
|
if capability_key in {"upload", "file", "file-provider"}:
|
||||||
return _supports_upload(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
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -514,6 +526,18 @@ def _info_supports_capability(info: PluginInfo, capability: str) -> bool:
|
|||||||
return bool(info.supports_search)
|
return bool(info.supports_search)
|
||||||
if capability_key in {"upload", "file", "file-provider"}:
|
if capability_key in {"upload", "file", "file-provider"}:
|
||||||
return bool(info.supports_upload)
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+12
-7
@@ -5,6 +5,7 @@ from importlib import import_module, reload as reload_module
|
|||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
import logging
|
import logging
|
||||||
|
from ProviderCore.commands import get_primary_command_object
|
||||||
from ProviderCore.registry import get_plugin
|
from ProviderCore.registry import get_plugin
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
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)
|
normalized = _normalize_mod_name(mod_name)
|
||||||
if not normalized:
|
if not normalized:
|
||||||
return None
|
return None
|
||||||
for package in ("cmdnat", "cmdlet", None):
|
for qualified in (
|
||||||
|
f"plugins.{normalized}.commands",
|
||||||
|
f"cmdnat.{normalized}",
|
||||||
|
f"cmdlet.{normalized}",
|
||||||
|
normalized,
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
# When attempting a bare import (package is None), prefer the repo-local
|
# When attempting a bare import (package is None), prefer the repo-local
|
||||||
# `MPV` package for the `mpv` module name so we don't accidentally
|
# `MPV` package for the `mpv` module name so we don't accidentally
|
||||||
# import the third-party `mpv` package (python-mpv) which can raise
|
# import the third-party `mpv` package (python-mpv) which can raise
|
||||||
# OSError if system libmpv is missing.
|
# OSError if system libmpv is missing.
|
||||||
if package is None and normalized == "mpv":
|
if qualified == normalized and normalized == "mpv":
|
||||||
try:
|
try:
|
||||||
if reload_loaded and "MPV" in sys.modules:
|
if reload_loaded and "MPV" in sys.modules:
|
||||||
return reload_module(sys.modules["MPV"])
|
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.
|
# Local MPV package not present; fall back to the normal bare import.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
qualified = f"{package}.{normalized}" if package else normalized
|
|
||||||
if reload_loaded and qualified in sys.modules:
|
if reload_loaded and qualified in sys.modules:
|
||||||
return reload_module(sys.modules[qualified])
|
return reload_module(sys.modules[qualified])
|
||||||
return import_module(qualified)
|
return import_module(qualified)
|
||||||
@@ -151,7 +156,7 @@ def get_cmdlet_metadata(
|
|||||||
ensure_registry_loaded()
|
ensure_registry_loaded()
|
||||||
normalized = cmd_name.replace("-", "_")
|
normalized = cmd_name.replace("-", "_")
|
||||||
mod = import_cmd_module(normalized)
|
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:
|
if data is None:
|
||||||
try:
|
try:
|
||||||
@@ -161,7 +166,7 @@ def get_cmdlet_metadata(
|
|||||||
owner_mod = getattr(reg_fn, "__module__", "")
|
owner_mod = getattr(reg_fn, "__module__", "")
|
||||||
if owner_mod:
|
if owner_mod:
|
||||||
owner = import_module(owner_mod)
|
owner = import_module(owner_mod)
|
||||||
data = getattr(owner, "CMDLET", None)
|
data = get_primary_command_object(owner)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Registry fallback failed while resolving cmdlet %s: %s", cmd_name, exc)
|
logger.exception("Registry fallback failed while resolving cmdlet %s: %s", cmd_name, exc)
|
||||||
data = None
|
data = None
|
||||||
@@ -371,7 +376,7 @@ def get_cmdlet_arg_choices(
|
|||||||
ids = []
|
ids = []
|
||||||
|
|
||||||
if 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:
|
try:
|
||||||
hs = matrix_conf.get("homeserver")
|
hs = matrix_conf.get("homeserver")
|
||||||
token = matrix_conf.get("access_token")
|
token = matrix_conf.get("access_token")
|
||||||
|
|||||||
+7
-3
@@ -33,13 +33,17 @@ class CmdletArg:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
def to_flags(self) -> tuple[str, ...]:
|
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:
|
if self.alias:
|
||||||
flags.append(f"-{self.alias}")
|
flags.append(f"-{self.alias}")
|
||||||
|
|
||||||
if self.type == "flag":
|
if self.type == "flag":
|
||||||
flags.append(f"--no-{self.name}")
|
flags.append(f"--no-{normalized_name}")
|
||||||
flags.append(f"-no{self.name}")
|
flags.append(f"-no{normalized_name}")
|
||||||
if self.alias:
|
if self.alias:
|
||||||
flags.append(f"-n{self.alias}")
|
flags.append(f"-n{self.alias}")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Iterable, List, Optional, Sequence
|
||||||
|
|
||||||
|
VALUE_ARG_FLAGS = frozenset({"-value", "--value", "-set-value", "--set-value"})
|
||||||
|
|
||||||
|
|
||||||
|
def extract_piped_value(result: Any) -> Optional[str]:
|
||||||
|
if isinstance(result, str):
|
||||||
|
return result.strip() if result.strip() else None
|
||||||
|
if isinstance(result, (int, float)):
|
||||||
|
return str(result)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
value = result.get("value")
|
||||||
|
if value is not None:
|
||||||
|
return str(value).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_arg_value(
|
||||||
|
args: Sequence[str],
|
||||||
|
*,
|
||||||
|
flags: Iterable[str],
|
||||||
|
allow_positional: bool = False,
|
||||||
|
) -> Optional[str]:
|
||||||
|
if not args:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tokens = [str(tok) for tok in args if tok is not None]
|
||||||
|
normalized_flags = {
|
||||||
|
str(flag).strip().lower() for flag in flags if str(flag).strip()
|
||||||
|
}
|
||||||
|
if not normalized_flags:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for idx, tok in enumerate(tokens):
|
||||||
|
text = tok.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
low = text.lower()
|
||||||
|
if low in normalized_flags and idx + 1 < len(tokens):
|
||||||
|
candidate = str(tokens[idx + 1]).strip()
|
||||||
|
if candidate:
|
||||||
|
return candidate
|
||||||
|
if "=" in low:
|
||||||
|
head, value = low.split("=", 1)
|
||||||
|
if head in normalized_flags and value:
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
if not allow_positional:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for tok in tokens:
|
||||||
|
text = str(tok).strip()
|
||||||
|
if text and not text.startswith("-"):
|
||||||
|
return text
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_value_arg(args: Sequence[str]) -> Optional[str]:
|
||||||
|
return extract_arg_value(args, flags=VALUE_ARG_FLAGS, allow_positional=True)
|
||||||
|
|
||||||
|
|
||||||
|
def has_flag(args: Sequence[str], flag: str) -> bool:
|
||||||
|
try:
|
||||||
|
want = str(flag or "").strip().lower()
|
||||||
|
if not want:
|
||||||
|
return False
|
||||||
|
return any(str(arg).strip().lower() == want for arg in (args or []))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_to_list(value: Any) -> List[Any]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
return [value]
|
||||||
@@ -769,14 +769,6 @@ def set_last_result_table_overlay(
|
|||||||
state.display_items = items or []
|
state.display_items = items or []
|
||||||
state.display_subject = subject
|
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:
|
def set_last_result_items_only(items: Optional[List[Any]]) -> None:
|
||||||
"""
|
"""
|
||||||
Store items for @N selection WITHOUT affecting history or saved search data.
|
Store items for @N selection WITHOUT affecting history or saved search data.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any, Dict, Iterable, List, Optional
|
|||||||
|
|
||||||
from SYS.config import global_config
|
from SYS.config import global_config
|
||||||
from ProviderCore.registry import get_plugin_class, list_plugins
|
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__)
|
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]:
|
def get_store_schema(store_type: str) -> List[ConfigField]:
|
||||||
classes = _discover_store_classes()
|
cls = _resolve_store_class(str(store_type or "").strip())
|
||||||
cls = classes.get(str(store_type or "").strip())
|
|
||||||
if cls is None:
|
if cls is None:
|
||||||
return []
|
return []
|
||||||
return _call_schema(cls, f"store '{store_type}'")
|
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", "")
|
config[key] = field.get("default", "")
|
||||||
return config
|
return config
|
||||||
|
|
||||||
classes = _discover_store_classes()
|
cls = _resolve_store_class(str(store_type or "").strip())
|
||||||
cls = classes.get(str(store_type or "").strip())
|
|
||||||
if cls is None:
|
if cls is None:
|
||||||
return config
|
return config
|
||||||
for required_key in _required_keys_for(cls):
|
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-"):
|
if normalized_type.startswith("store-"):
|
||||||
store_type = normalized_type.replace("store-", "", 1)
|
store_type = normalized_type.replace("store-", "", 1)
|
||||||
classes = _discover_store_classes()
|
cls = _resolve_store_class(store_type)
|
||||||
cls = classes.get(store_type)
|
|
||||||
if cls is not None:
|
if cls is not None:
|
||||||
for required_key in _required_keys_for(cls):
|
for required_key in _required_keys_for(cls):
|
||||||
_add_key(required_key)
|
_add_key(required_key)
|
||||||
|
|||||||
+72
-1
@@ -13,7 +13,7 @@ Features:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
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
|
from pathlib import Path
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@@ -94,6 +94,42 @@ def _partition_detail_tags(tags: Any) -> tuple[List[str], List[str]]:
|
|||||||
return namespace_tags, freeform_tags
|
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]]:
|
def _chunk_detail_tags(tags: List[str], columns: int) -> List[List[str]]:
|
||||||
column_count = max(1, int(columns or 1))
|
column_count = max(1, int(columns or 1))
|
||||||
rows: List[List[str]] = []
|
rows: List[List[str]] = []
|
||||||
@@ -954,6 +990,41 @@ class Table:
|
|||||||
|
|
||||||
return self
|
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":
|
def add_result(self, result: Any) -> "Table":
|
||||||
"""Add a result object (SearchResult, PipeObject, ResultItem, TagItem, or dict) as a row.
|
"""Add a result object (SearchResult, PipeObject, ResultItem, TagItem, or dict) as a row.
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+99
-3
@@ -25,6 +25,7 @@ from Store._base import Store as BaseStore
|
|||||||
_SHA256_HEX_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
_SHA256_HEX_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||||
|
|
||||||
_DISCOVERED_CLASSES_CACHE: Optional[Dict[str, Type[BaseStore]]] = None
|
_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.
|
# 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>.
|
# 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
|
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]:
|
def _required_keys_for(store_cls: Type[BaseStore]) -> list[str]:
|
||||||
# Support new config_schema() schema
|
# Support new config_schema() schema
|
||||||
if hasattr(store_cls, "config_schema") and callable(store_cls.config_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))
|
store_type = _normalize_store_type(str(raw_store_type))
|
||||||
if store_type == "folder":
|
if store_type == "folder":
|
||||||
continue
|
continue
|
||||||
store_cls = classes_by_type.get(store_type)
|
store_cls = _resolve_store_class(store_type, classes_by_type)
|
||||||
if store_cls is None:
|
if store_cls is None:
|
||||||
# Skip provider-only names without debug warning
|
# Skip provider-only names without debug warning
|
||||||
if store_type not in _PROVIDER_ONLY_STORE_NAMES and not self._suppress_debug:
|
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:
|
if store_type == "folder" or store_type in _PROVIDER_ONLY_STORE_NAMES:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
store_cls = classes_by_type.get(store_type)
|
store_cls = _resolve_store_class(store_type, classes_by_type)
|
||||||
if store_cls is None:
|
if store_cls is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -417,7 +513,7 @@ def get_backend_instance(config: Optional[Dict[str, Any]], backend_name: str, *,
|
|||||||
if not isinstance(instances, dict):
|
if not isinstance(instances, dict):
|
||||||
continue
|
continue
|
||||||
store_type = _normalize_store_type(str(raw_store_type))
|
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:
|
if store_cls is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,17 @@ def _register_native_commands() -> None:
|
|||||||
pass
|
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:
|
def ensure_cmdlet_modules_loaded(force: bool = False) -> None:
|
||||||
global _MODULES_LOADED
|
global _MODULES_LOADED
|
||||||
|
|
||||||
@@ -115,4 +126,5 @@ def ensure_cmdlet_modules_loaded(force: bool = False) -> None:
|
|||||||
_load_root_modules()
|
_load_root_modules()
|
||||||
_load_helper_modules()
|
_load_helper_modules()
|
||||||
_register_native_commands()
|
_register_native_commands()
|
||||||
|
_register_plugin_commands()
|
||||||
_MODULES_LOADED = True
|
_MODULES_LOADED = True
|
||||||
|
|||||||
+421
-5
@@ -105,9 +105,13 @@ class CmdletArg:
|
|||||||
storage_flags = SharedArgs.STORAGE.to_flags()
|
storage_flags = SharedArgs.STORAGE.to_flags()
|
||||||
# Returns: ('--storage', '-storage', '-s')
|
# Returns: ('--storage', '-storage', '-s')
|
||||||
"""
|
"""
|
||||||
|
normalized_name = str(self.name or "").lstrip("-")
|
||||||
|
if not normalized_name:
|
||||||
|
return tuple()
|
||||||
|
|
||||||
flags = [
|
flags = [
|
||||||
f"--{self.name}",
|
f"--{normalized_name}",
|
||||||
f"-{self.name}"
|
f"-{normalized_name}"
|
||||||
] # Both double-dash and single-dash variants
|
] # Both double-dash and single-dash variants
|
||||||
|
|
||||||
# Add short form if alias exists
|
# Add short form if alias exists
|
||||||
@@ -116,8 +120,8 @@ class CmdletArg:
|
|||||||
|
|
||||||
# Add negation forms for flag type
|
# Add negation forms for flag type
|
||||||
if self.type == "flag":
|
if self.type == "flag":
|
||||||
flags.append(f"--no-{self.name}")
|
flags.append(f"--no-{normalized_name}")
|
||||||
flags.append(f"-no{self.name}") # Single-dash negation variant
|
flags.append(f"-no{normalized_name}") # Single-dash negation variant
|
||||||
if self.alias:
|
if self.alias:
|
||||||
flags.append(f"-n{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)
|
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]:
|
def _expand_pipe_namespace(text: str) -> List[str]:
|
||||||
parts = text.split("|")
|
parts = text.split("|")
|
||||||
expanded: List[str] = []
|
expanded: List[str] = []
|
||||||
@@ -1684,7 +1741,7 @@ def parse_tag_arguments(arguments: Sequence[str]) -> List[str]:
|
|||||||
|
|
||||||
tags: List[str] = []
|
tags: List[str] = []
|
||||||
for argument in arguments:
|
for argument in arguments:
|
||||||
for token in argument.split(","):
|
for token in _split_top_level_commas(str(argument)):
|
||||||
text = token.strip()
|
text = token.strip()
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
@@ -1704,6 +1761,365 @@ def parse_tag_arguments(arguments: Sequence[str]) -> List[str]:
|
|||||||
return tags
|
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:
|
def fmt_bytes(n: Optional[int]) -> str:
|
||||||
"""Format bytes as human-readable with 1 decimal place (MB/GB).
|
"""Format bytes as human-readable with 1 decimal place (MB/GB).
|
||||||
|
|
||||||
|
|||||||
+111
-19
@@ -337,6 +337,13 @@ class Add_File(Cmdlet):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
debug(f"[add-file] Directory scan failed: {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).
|
# Determine if -store targets a registered backend (vs a filesystem export path).
|
||||||
is_storage_backend_location = False
|
is_storage_backend_location = False
|
||||||
if location:
|
if location:
|
||||||
@@ -354,6 +361,19 @@ class Add_File(Cmdlet):
|
|||||||
)
|
)
|
||||||
return 1
|
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.
|
# Decide which items to process.
|
||||||
# - If directory scan was performed, use those results
|
# - 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.
|
# - 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:
|
else:
|
||||||
items_to_process = [result]
|
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
|
total_items = len(items_to_process) if isinstance(items_to_process, list) else 0
|
||||||
processed_items = 0
|
processed_items = 0
|
||||||
try:
|
try:
|
||||||
@@ -549,15 +562,16 @@ class Add_File(Cmdlet):
|
|||||||
live_progress = None
|
live_progress = None
|
||||||
|
|
||||||
want_final_search_file = (
|
want_final_search_file = (
|
||||||
bool(is_last_stage) and bool(is_storage_backend_location)
|
bool(is_last_stage)
|
||||||
and bool(location) and bool(live_progress)
|
and bool(effective_storage_backend_name)
|
||||||
|
and bool(live_progress)
|
||||||
)
|
)
|
||||||
auto_search_file_after_add = False
|
auto_search_file_after_add = False
|
||||||
|
|
||||||
# When ingesting multiple items into a backend store, defer URL association and
|
# 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.
|
# apply it once at the end (bulk) to avoid per-item URL API calls.
|
||||||
defer_url_association = (
|
defer_url_association = (
|
||||||
bool(is_storage_backend_location) and bool(location)
|
bool(effective_storage_backend_name)
|
||||||
and len(items_to_process) > 1
|
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 -path (filesystem export), allow all file types.
|
||||||
# When using -store (backend), restrict to SUPPORTED_MEDIA_EXTENSIONS.
|
# 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):
|
if not self._validate_source(media_path, allow_all_extensions=allow_all_files):
|
||||||
failures += 1
|
failures += 1
|
||||||
continue
|
continue
|
||||||
@@ -653,14 +667,33 @@ class Add_File(Cmdlet):
|
|||||||
progress.step("ingesting file")
|
progress.step("ingesting file")
|
||||||
|
|
||||||
if plugin_name:
|
if plugin_name:
|
||||||
code = self._handle_plugin_upload(
|
if effective_storage_backend_name:
|
||||||
media_path,
|
code = self._handle_storage_backend(
|
||||||
plugin_name,
|
item,
|
||||||
plugin_instance,
|
media_path,
|
||||||
pipe_obj,
|
effective_storage_backend_name,
|
||||||
config,
|
pipe_obj,
|
||||||
delete_after_item
|
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:
|
if code == 0:
|
||||||
successes += 1
|
successes += 1
|
||||||
else:
|
else:
|
||||||
@@ -1431,6 +1464,65 @@ class Add_File(Cmdlet):
|
|||||||
normalized = normalized.split(".", 1)[0]
|
normalized = normalized.split(".", 1)[0]
|
||||||
return normalized
|
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
|
@staticmethod
|
||||||
def _maybe_download_plugin_result(
|
def _maybe_download_plugin_result(
|
||||||
result: Any,
|
result: Any,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ SharedArgs = sh.SharedArgs
|
|||||||
normalize_hash = sh.normalize_hash
|
normalize_hash = sh.normalize_hash
|
||||||
parse_tag_arguments = sh.parse_tag_arguments
|
parse_tag_arguments = sh.parse_tag_arguments
|
||||||
expand_tag_groups = sh.expand_tag_groups
|
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
|
parse_cmdlet_args = sh.parse_cmdlet_args
|
||||||
collapse_namespace_tag = sh.collapse_namespace_tag
|
collapse_namespace_tag = sh.collapse_namespace_tag
|
||||||
should_show_help = sh.should_show_help
|
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.",
|
"- The source namespace must already exist in the file being tagged.",
|
||||||
"- Target namespaces that already have a value are skipped (not overwritten).",
|
"- 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 -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,
|
exec=self.run,
|
||||||
)
|
)
|
||||||
@@ -655,6 +662,7 @@ class Add_Tag(Cmdlet):
|
|||||||
# tag ARE provided - apply them to each store-backed result
|
# tag ARE provided - apply them to each store-backed result
|
||||||
total_added = 0
|
total_added = 0
|
||||||
total_modified = 0
|
total_modified = 0
|
||||||
|
unresolved_template_count = 0
|
||||||
|
|
||||||
store_registry = Store(config, suppress_debug=True)
|
store_registry = Store(config, suppress_debug=True)
|
||||||
|
|
||||||
@@ -791,6 +799,13 @@ class Add_Tag(Cmdlet):
|
|||||||
if new_tag.lower() not in existing_lower:
|
if new_tag.lower() not in existing_lower:
|
||||||
item_tag_to_add.append(new_tag)
|
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 = collapse_namespace_tag(
|
||||||
item_tag_to_add,
|
item_tag_to_add,
|
||||||
"title",
|
"title",
|
||||||
@@ -962,6 +977,13 @@ class Add_Tag(Cmdlet):
|
|||||||
if new_tag.lower() not in existing_lower:
|
if new_tag.lower() not in existing_lower:
|
||||||
item_tag_to_add.append(new_tag)
|
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 = collapse_namespace_tag(
|
||||||
item_tag_to_add,
|
item_tag_to_add,
|
||||||
"title",
|
"title",
|
||||||
@@ -1109,6 +1131,12 @@ class Add_Tag(Cmdlet):
|
|||||||
file=sys.stderr,
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+34
-3
@@ -11,6 +11,9 @@ CmdletArg = sh.CmdletArg
|
|||||||
SharedArgs = sh.SharedArgs
|
SharedArgs = sh.SharedArgs
|
||||||
normalize_hash = sh.normalize_hash
|
normalize_hash = sh.normalize_hash
|
||||||
parse_tag_arguments = sh.parse_tag_arguments
|
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
|
should_show_help = sh.should_show_help
|
||||||
get_field = sh.get_field
|
get_field = sh.get_field
|
||||||
from SYS.logger import debug, log
|
from SYS.logger import debug, log
|
||||||
@@ -133,6 +136,11 @@ CMDLET = Cmdlet(
|
|||||||
detail=[
|
detail=[
|
||||||
"- Requires a Hydrus file (hash present) or explicit -query override.",
|
"- Requires a Hydrus file (hash present) or explicit -query override.",
|
||||||
"- Multiple tags can be comma-separated or space-separated.",
|
"- 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")
|
store_name = override_store or get_field(result, "store")
|
||||||
path = get_field(result, "path") or get_field(result, "target")
|
path = get_field(result, "path") or get_field(result, "target")
|
||||||
tags = [str(t) for t in grouped_tags if t]
|
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:
|
if not tags_arg and not has_piped_tag and not has_piped_tag_list:
|
||||||
log("Requires at least one tag argument")
|
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_hash,
|
||||||
item_path,
|
item_path,
|
||||||
item_store,
|
item_store,
|
||||||
config):
|
config,
|
||||||
|
result=item):
|
||||||
success_count += 1
|
success_count += 1
|
||||||
|
|
||||||
if success_count > 0:
|
if success_count > 0:
|
||||||
@@ -331,6 +340,7 @@ def _process_deletion(
|
|||||||
store_name: str | None,
|
store_name: str | None,
|
||||||
config: Dict[str,
|
config: Dict[str,
|
||||||
Any],
|
Any],
|
||||||
|
result: Any = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Helper to execute the deletion logic for a single target."""
|
"""Helper to execute the deletion logic for a single target."""
|
||||||
|
|
||||||
@@ -367,12 +377,33 @@ def _process_deletion(
|
|||||||
except Exception:
|
except Exception:
|
||||||
return []
|
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
|
# Safety: only block if this deletion would remove the final title tag
|
||||||
title_tags = [
|
title_tags = [
|
||||||
t for t in tags if isinstance(t, str) and t.lower().startswith("title:")
|
t for t in tags if isinstance(t, str) and t.lower().startswith("title:")
|
||||||
]
|
]
|
||||||
if title_tags:
|
if title_tags:
|
||||||
existing_tags = _fetch_existing_tags()
|
existing_tags = existing_tag_list
|
||||||
current_titles = [
|
current_titles = [
|
||||||
t for t in existing_tags
|
t for t in existing_tags
|
||||||
if isinstance(t, str) and t.lower().startswith("title:")
|
if isinstance(t, str) and t.lower().startswith("title:")
|
||||||
|
|||||||
@@ -1231,7 +1231,7 @@ class search_file(Cmdlet):
|
|||||||
log(f"No web results found for query: {search_query}", file=sys.stderr)
|
log(f"No web results found for query: {search_query}", file=sys.stderr)
|
||||||
if refresh_mode:
|
if refresh_mode:
|
||||||
try:
|
try:
|
||||||
ctx.set_last_result_table_preserve_history(table, [])
|
ctx.set_last_result_table_overlay(table, [])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
@@ -2089,7 +2089,7 @@ class search_file(Cmdlet):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if refresh_mode:
|
if refresh_mode:
|
||||||
ctx.set_last_result_table_preserve_history(
|
ctx.set_last_result_table_overlay(
|
||||||
table,
|
table,
|
||||||
results_list
|
results_list
|
||||||
)
|
)
|
||||||
@@ -2106,7 +2106,7 @@ class search_file(Cmdlet):
|
|||||||
if refresh_mode:
|
if refresh_mode:
|
||||||
try:
|
try:
|
||||||
table.title = command_title
|
table.title = command_title
|
||||||
ctx.set_last_result_table_preserve_history(table, [])
|
ctx.set_last_result_table_overlay(table, [])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
db.append_worker_stdout(worker_id, _summarize_worker_results([]))
|
db.append_worker_stdout(worker_id, _summarize_worker_results([]))
|
||||||
@@ -2279,7 +2279,7 @@ class search_file(Cmdlet):
|
|||||||
if refresh_mode:
|
if refresh_mode:
|
||||||
try:
|
try:
|
||||||
table.title = command_title
|
table.title = command_title
|
||||||
ctx.set_last_result_table_preserve_history(table, [])
|
ctx.set_last_result_table_overlay(table, [])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
db.append_worker_stdout(worker_id, _summarize_worker_results([]))
|
db.append_worker_stdout(worker_id, _summarize_worker_results([]))
|
||||||
|
|||||||
+8
-3
@@ -28,15 +28,20 @@ def _register_cmdlet_object(cmdlet_obj, registry: Dict[str, CmdletFn]) -> None:
|
|||||||
registry[alias.replace("_", "-").lower()] = run_fn
|
registry[alias.replace("_", "-").lower()] = run_fn
|
||||||
|
|
||||||
|
|
||||||
def register_native_commands(registry: Dict[str, CmdletFn]) -> None:
|
def _iter_legacy_native_module_names() -> list[str]:
|
||||||
"""Import native command modules and register their CMDLET exec functions."""
|
|
||||||
base_dir = os.path.dirname(__file__)
|
base_dir = os.path.dirname(__file__)
|
||||||
|
module_names: list[str] = []
|
||||||
for filename in os.listdir(base_dir):
|
for filename in os.listdir(base_dir):
|
||||||
if not (filename.endswith(".py") and not filename.startswith("_")
|
if not (filename.endswith(".py") and not filename.startswith("_")
|
||||||
and filename != "__init__.py"):
|
and filename != "__init__.py"):
|
||||||
continue
|
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:
|
try:
|
||||||
module = import_module(f".{mod_name}", __name__)
|
module = import_module(f".{mod_name}", __name__)
|
||||||
cmdlet_obj = getattr(module, "CMDLET", None)
|
cmdlet_obj = getattr(module, "CMDLET", None)
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, Sequence, Optional
|
|
||||||
|
|
||||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg
|
|
||||||
from SYS.logger import log
|
|
||||||
from SYS import pipeline as ctx
|
|
||||||
|
|
||||||
CMDLET = Cmdlet(
|
|
||||||
name=".out-table",
|
|
||||||
summary="Save the current result table to an SVG file.",
|
|
||||||
usage='.out-table -path "C:\\Path\\To\\Dir"',
|
|
||||||
arg=[
|
|
||||||
CmdletArg(
|
|
||||||
"path",
|
|
||||||
type="string",
|
|
||||||
description="Directory (or file path) to write the SVG to",
|
|
||||||
required=True,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
detail=[
|
|
||||||
"Exports the most recent table (overlay/stage/last) as an SVG using Rich.",
|
|
||||||
"Default filename is derived from the table title (sanitized).",
|
|
||||||
"Examples:",
|
|
||||||
'search-file "ext:mp3" | .out-table -path "C:\\Users\\Admin\\Desktop"',
|
|
||||||
'search-file "ext:mp3" | .out-table -path "C:\\Users\\Admin\\Desktop\\my-table.svg"',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
_WINDOWS_RESERVED_NAMES = {
|
|
||||||
"con",
|
|
||||||
"prn",
|
|
||||||
"aux",
|
|
||||||
"nul",
|
|
||||||
*(f"com{i}" for i in range(1, 10)),
|
|
||||||
*(f"lpt{i}" for i in range(1, 10)),
|
|
||||||
}
|
|
||||||
_ILLEGAL_FILENAME_CHARS_RE = re.compile(r'[<>:"/\\|?*]')
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_filename_base(text: str) -> str:
|
|
||||||
"""Sanitize a string for use as a Windows-friendly filename (no extension)."""
|
|
||||||
s = str(text or "").strip()
|
|
||||||
if not s:
|
|
||||||
return "table"
|
|
||||||
|
|
||||||
# Replace characters illegal on Windows (and generally unsafe cross-platform).
|
|
||||||
s = _ILLEGAL_FILENAME_CHARS_RE.sub(" ", s)
|
|
||||||
|
|
||||||
# Drop control characters.
|
|
||||||
s = "".join(ch for ch in s if ch.isprintable())
|
|
||||||
|
|
||||||
# Collapse whitespace.
|
|
||||||
s = " ".join(s.split()).strip()
|
|
||||||
|
|
||||||
# Windows disallows trailing space/dot.
|
|
||||||
s = s.rstrip(" .")
|
|
||||||
|
|
||||||
if not s:
|
|
||||||
s = "table"
|
|
||||||
|
|
||||||
# Avoid reserved device names.
|
|
||||||
if s.lower() in _WINDOWS_RESERVED_NAMES:
|
|
||||||
s = f"_{s}"
|
|
||||||
|
|
||||||
# Keep it reasonably short.
|
|
||||||
if len(s) > 200:
|
|
||||||
s = s[:200].rstrip(" .")
|
|
||||||
|
|
||||||
return s or "table"
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_output_path(path_arg: str, *, table_title: str) -> Path:
|
|
||||||
raw = str(path_arg or "").strip()
|
|
||||||
if not raw:
|
|
||||||
raise ValueError("-path is required")
|
|
||||||
|
|
||||||
# Treat trailing slash as directory intent even if it doesn't exist yet.
|
|
||||||
ends_with_sep = raw.endswith((os.sep, os.altsep or ""))
|
|
||||||
|
|
||||||
target = Path(raw)
|
|
||||||
|
|
||||||
if target.exists() and target.is_dir():
|
|
||||||
base = _sanitize_filename_base(table_title)
|
|
||||||
return target / f"{base}.svg"
|
|
||||||
|
|
||||||
if ends_with_sep and not target.suffix:
|
|
||||||
target.mkdir(parents=True, exist_ok=True)
|
|
||||||
base = _sanitize_filename_base(table_title)
|
|
||||||
return target / f"{base}.svg"
|
|
||||||
|
|
||||||
# File path intent.
|
|
||||||
if not target.suffix:
|
|
||||||
return target.with_suffix(".svg")
|
|
||||||
|
|
||||||
if target.suffix.lower() != ".svg":
|
|
||||||
return target.with_suffix(".svg")
|
|
||||||
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def _get_active_table(piped_result: Any) -> Optional[Any]:
|
|
||||||
# Prefer an explicit ResultTable passed through the pipe, but normally `.out-table`
|
|
||||||
# is used after `@` which pipes item selections (not the table itself).
|
|
||||||
if piped_result is not None and hasattr(piped_result, "__rich__"):
|
|
||||||
# Avoid mistakenly treating a dict/list as a renderable.
|
|
||||||
if piped_result.__class__.__name__ == "ResultTable":
|
|
||||||
return piped_result
|
|
||||||
|
|
||||||
return ctx.get_display_table() or ctx.get_current_stage_table(
|
|
||||||
) or ctx.get_last_result_table()
|
|
||||||
|
|
||||||
|
|
||||||
def _run(piped_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|
||||||
args_list = [str(a) for a in (args or [])]
|
|
||||||
|
|
||||||
# Simple flag parsing: `.out-table -path <value>`
|
|
||||||
path_arg: Optional[str] = None
|
|
||||||
i = 0
|
|
||||||
while i < len(args_list):
|
|
||||||
low = args_list[i].strip().lower()
|
|
||||||
if low in {"-path",
|
|
||||||
"--path"} and i + 1 < len(args_list):
|
|
||||||
path_arg = args_list[i + 1]
|
|
||||||
i += 2
|
|
||||||
continue
|
|
||||||
if not args_list[i].startswith("-") and path_arg is None:
|
|
||||||
# Allow `.out-table <path>` as a convenience.
|
|
||||||
path_arg = args_list[i]
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
if not path_arg:
|
|
||||||
log("Missing required -path", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
table = _get_active_table(piped_result)
|
|
||||||
if table is None:
|
|
||||||
log("No table available to export", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
title = getattr(table, "title", None)
|
|
||||||
title_text = str(title or "table")
|
|
||||||
|
|
||||||
try:
|
|
||||||
out_path = _resolve_output_path(path_arg, table_title=title_text)
|
|
||||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
from rich.console import Console
|
|
||||||
|
|
||||||
console = Console(record=True)
|
|
||||||
console.print(table)
|
|
||||||
console.save_svg(str(out_path))
|
|
||||||
|
|
||||||
log(f"Saved table SVG: {out_path}")
|
|
||||||
return 0
|
|
||||||
except Exception as exc:
|
|
||||||
log(f"Failed to save table SVG: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
CMDLET.exec = _run
|
|
||||||
+1
-1
@@ -41,7 +41,7 @@ def _run(result: Any, args: List[str], config: Dict[str, Any]) -> int:
|
|||||||
try:
|
try:
|
||||||
# MPV check
|
# MPV check
|
||||||
try:
|
try:
|
||||||
from MPV.mpv_ipc import MPV
|
from plugins.mpv.mpv_ipc import MPV
|
||||||
MPV()
|
MPV()
|
||||||
mpv_path = shutil.which("mpv")
|
mpv_path = shutil.which("mpv")
|
||||||
_add_startup_check(startup_table, "ENABLED", "MPV", detail=mpv_path or "Available")
|
_add_startup_check(startup_table, "ENABLED", "MPV", detail=mpv_path or "Available")
|
||||||
|
|||||||
+350
-6
@@ -1,17 +1,335 @@
|
|||||||
from typing import Any, Dict, Sequence
|
from __future__ import annotations
|
||||||
|
|
||||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Sequence, Tuple
|
||||||
|
|
||||||
|
from SYS.cmdlet_spec import Cmdlet, CmdletArg, SharedArgs, parse_cmdlet_args
|
||||||
from SYS.logger import log
|
from SYS.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:
|
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:
|
try:
|
||||||
from SYS import pipeline as ctx
|
from SYS import pipeline as ctx
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"Failed to import pipeline context: {exc}")
|
log(f"Failed to import pipeline context: {exc}")
|
||||||
return 1
|
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
|
state = None
|
||||||
try:
|
try:
|
||||||
state = ctx.get_pipeline_state() if hasattr(ctx, "get_pipeline_state") else None
|
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(
|
CMDLET = Cmdlet(
|
||||||
name=".table",
|
name=".table",
|
||||||
summary="Dump pipeline table state for debugging",
|
alias=["table"],
|
||||||
usage=".table [label]",
|
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=[
|
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(
|
CmdletArg(
|
||||||
name="label",
|
name="label",
|
||||||
type="string",
|
type="string",
|
||||||
description="Optional label to include in the dump",
|
description="Optional label to include in the debug dump",
|
||||||
required=False,
|
required=False,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ UOSC defines its own right-click menu, and there's no straightforward way to ove
|
|||||||
3. **Right-click** - Attempts to trigger via input.conf, but UOSC overrides (needs investigation)
|
3. **Right-click** - Attempts to trigger via input.conf, but UOSC overrides (needs investigation)
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
- `MPV/portable_config/input.conf` routes keybindings to Lua handlers
|
- `plugins/mpv/portable_config/input.conf` routes keybindings to Lua handlers
|
||||||
- `MPV/LUA/main.lua`:
|
- `plugins/mpv/LUA/main.lua`:
|
||||||
- Registers script message handler: `medios-show-menu`
|
- Registers script message handler: `medios-show-menu`
|
||||||
- Registers Lua keybindings for 'm' and 'z' keys
|
- Registers Lua keybindings for 'm' and 'z' keys
|
||||||
- Both route to `M.show_menu()` which opens an UOSC menu with items
|
- 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)
|
- Start Helper (if not running)
|
||||||
|
|
||||||
## Files Modified
|
## Files Modified
|
||||||
- `MPV/LUA/main.lua`:
|
- `plugins/mpv/LUA/main.lua`:
|
||||||
- Fixed Lua syntax error (extra `end)`)
|
- Fixed Lua syntax error (extra `end)`)
|
||||||
- Added comprehensive `[MENU]` and `[KEY]` logging
|
- Added comprehensive `[MENU]` and `[KEY]` logging
|
||||||
- Added 'm' and 'z' keybindings
|
- Added 'm' and 'z' keybindings
|
||||||
- Added `medios-show-menu` script message handler
|
- Added `medios-show-menu` script message handler
|
||||||
- Enhanced `M.show_menu()` with dual methods to call UOSC
|
- 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 `mbtn_right` to `script-message medios-show-menu`
|
||||||
- Routes 'm' key 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`)
|
- Fixed `audio-display` setting (was invalid `yes`, now `no`)
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
Run: `python test_menu.py`
|
Run: `python test_menu.py`
|
||||||
|
|
||||||
Or manually:
|
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
|
2. Press 'm' or 'z' key
|
||||||
3. Check logs at `Log/medeia-mpv-lua.log` for `[MENU]` entries
|
3. Check logs at `Log/medeia-mpv-lua.log` for `[MENU]` entries
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
# Tag Template Syntax
|
||||||
|
|
||||||
|
This guide documents the reusable template syntax for tag mutation commands such as `add-tag` and `delete-tag`.
|
||||||
|
|
||||||
|
The current goal is lowercase-first tagging. Examples in this document use lowercase tag names and lowercase text values, and no case-conversion transforms are part of the documented syntax.
|
||||||
|
|
||||||
|
## Where It Works
|
||||||
|
|
||||||
|
The shared template resolver currently applies to:
|
||||||
|
|
||||||
|
- `add-tag`
|
||||||
|
- `delete-tag`
|
||||||
|
|
||||||
|
Templates are resolved per item against that item's current tag set and lightweight result fields such as the current title.
|
||||||
|
|
||||||
|
## Core Placeholder Syntax
|
||||||
|
|
||||||
|
Use `#(namespace)` to insert the value from an existing namespaced tag.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "title:#(track) - #(series)"
|
||||||
|
add-tag "album:#(series)"
|
||||||
|
delete-tag "title:#(track) - #(series)"
|
||||||
|
```
|
||||||
|
|
||||||
|
If an item has:
|
||||||
|
|
||||||
|
```text
|
||||||
|
track:9
|
||||||
|
series:ancient greek intensive course
|
||||||
|
```
|
||||||
|
|
||||||
|
then:
|
||||||
|
|
||||||
|
```text
|
||||||
|
title:#(track) - #(series)
|
||||||
|
```
|
||||||
|
|
||||||
|
resolves to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
title:9 - ancient greek intensive course
|
||||||
|
```
|
||||||
|
|
||||||
|
## Namespace Matching
|
||||||
|
|
||||||
|
- Namespace matching is case-insensitive.
|
||||||
|
- Repeated whitespace inside the placeholder is normalized.
|
||||||
|
- A trailing `#` is ignored for compatibility, so `#(track #)` resolves the same way as `#(track)`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "title:#(track #) - #(series)"
|
||||||
|
add-tag "code:#(disc number)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transform Syntax
|
||||||
|
|
||||||
|
Use angle brackets for transforms:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<name(arg1,arg2,...)>
|
||||||
|
```
|
||||||
|
|
||||||
|
Transforms run after `#(namespace)` placeholders are expanded.
|
||||||
|
|
||||||
|
### Padding
|
||||||
|
|
||||||
|
Use `padding`, `pad`, or `zfill` to zero-pad a value.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "code:e<padding(00,#(episode))>"
|
||||||
|
add-tag "code:e<pad(2,#(episode))>"
|
||||||
|
add-tag "code:e<zfill(2,#(episode))>"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `episode:3` exists, each example resolves to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
code:e03
|
||||||
|
```
|
||||||
|
|
||||||
|
Padding width can be written in either of these forms:
|
||||||
|
|
||||||
|
- `00` meaning width 2
|
||||||
|
- `000` meaning width 3
|
||||||
|
- `2` meaning width 2
|
||||||
|
- `3` meaning width 3
|
||||||
|
|
||||||
|
### Default
|
||||||
|
|
||||||
|
Use `default(value,fallback)` when a namespace may be missing.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "season:<default(#(season),0)>"
|
||||||
|
add-tag "disc:<default(#(disc),1)>"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `season:` is missing, the first example resolves to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
season:0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Replace
|
||||||
|
|
||||||
|
Use `replace(value,old,new)` for simple substring replacement.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "slug:<replace(#(title),' ',_)>"
|
||||||
|
add-tag "slug:<replace(#(series),-,_)>"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `title:ancient greek intensive course` exists, the first example resolves to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
slug:ancient_greek_intensive_course
|
||||||
|
```
|
||||||
|
|
||||||
|
Quote a space when you want to replace literal spaces. Bare spaces are trimmed by argument parsing, so `' '` is the reliable form.
|
||||||
|
|
||||||
|
### Increment
|
||||||
|
|
||||||
|
Use `increment(value,amount)` to do small integer adjustments.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "episode_next:<increment(#(episode),1)>"
|
||||||
|
add-tag "disc_next:<increment(#(disc),1)>"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `episode:3` exists, the first example resolves to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
episode_next:4
|
||||||
|
```
|
||||||
|
|
||||||
|
The second argument is optional; `<increment(#(episode))>` also adds `1`.
|
||||||
|
|
||||||
|
## Commas Inside Transforms
|
||||||
|
|
||||||
|
Tag arguments still support comma-separated tags, but commas inside transform calls are preserved.
|
||||||
|
|
||||||
|
This means the following stays as two tags, not three fragments:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "code:e<padding(00,#(episode))>,title:#(series)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Combining With `-extract`
|
||||||
|
|
||||||
|
Templates are especially useful after deriving tags from a title.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag -extract "(series) - part (track)" "title:#(track) - #(series)"
|
||||||
|
```
|
||||||
|
|
||||||
|
For a title like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ancient greek intensive course - part 9
|
||||||
|
```
|
||||||
|
|
||||||
|
this can derive:
|
||||||
|
|
||||||
|
```text
|
||||||
|
series:ancient greek intensive course
|
||||||
|
track:9
|
||||||
|
title:9 - ancient greek intensive course
|
||||||
|
```
|
||||||
|
|
||||||
|
## Missing Values
|
||||||
|
|
||||||
|
If a placeholder or transform cannot be resolved, the whole templated tag is skipped instead of being written literally.
|
||||||
|
|
||||||
|
Examples of skipped cases:
|
||||||
|
|
||||||
|
- `title:#(missing_namespace)` when no such tag exists
|
||||||
|
- `code:<padding(x,#(episode))>` when the padding width is invalid
|
||||||
|
|
||||||
|
The command logs a warning summary for skipped unresolved templates.
|
||||||
|
|
||||||
|
## Recommended Patterns
|
||||||
|
|
||||||
|
Episode-style numbering:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "code:e<padding(00,#(episode))>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Title synthesis from extracted tags:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag -extract "(series) - part (track)" "title:#(track) - #(series)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Delete a derived title tag:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
delete-tag "title:#(track) - #(series)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Reuse an existing value under a new namespace:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "album:#(series)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mass Tagging Recipes
|
||||||
|
|
||||||
|
These are the patterns most likely to be useful when cleaning or normalizing large existing tag sets.
|
||||||
|
|
||||||
|
### Build A Stable Episode Code
|
||||||
|
|
||||||
|
If items already have `episode:` values and you want a compact sortable code:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "code:e<padding(00,#(episode))>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
episode:3 -> code:e03
|
||||||
|
episode:12 -> code:e12
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Season-Episode Style Labels
|
||||||
|
|
||||||
|
If you already carry both season and episode values:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "code:s<padding(00,#(season))>e<padding(00,#(episode))>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
season:1
|
||||||
|
episode:3
|
||||||
|
```
|
||||||
|
|
||||||
|
becomes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
code:s01e03
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fill Missing Season Values Before Building A Code
|
||||||
|
|
||||||
|
If some items have episodes but no season tag yet:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "season:<default(#(season),0)>" "code:s<padding(00,#(season))>e<padding(00,#(episode))>"
|
||||||
|
```
|
||||||
|
|
||||||
|
That lets a later code template stay predictable even when the source metadata is incomplete.
|
||||||
|
|
||||||
|
### Rebuild A Title From Existing Tags
|
||||||
|
|
||||||
|
If you have normalized tags but want a cleaner `title:`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "title:#(series) - #(track)"
|
||||||
|
```
|
||||||
|
|
||||||
|
or for episode-style material:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "title:#(series) e<padding(00,#(episode))>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extract Then Reformat In One Pass
|
||||||
|
|
||||||
|
If the current title is messy but predictable:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag -extract "(series) - part (track)" "title:#(track) - #(series)"
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful when you want to convert display-oriented titles into searchable structured tags and then immediately synthesize a cleaner title back from them.
|
||||||
|
|
||||||
|
### Promote Existing Values Into New Namespaces
|
||||||
|
|
||||||
|
If one namespace already has the correct normalized value and you want to reuse it elsewhere:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "album:#(series)"
|
||||||
|
add-tag "label:#(publisher)"
|
||||||
|
add-tag "subtitle:#(title)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create URL-Safe Or Filename-Safe Slugs
|
||||||
|
|
||||||
|
If you want a simple underscore slug from an existing title:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "slug:<replace(#(title),' ',_)>"
|
||||||
|
```
|
||||||
|
|
||||||
|
For more involved slug cleanup, chain multiple commands over time by writing intermediate normalized tags instead of expecting one giant expression.
|
||||||
|
|
||||||
|
### Create "Next Episode" Or Offset Tags
|
||||||
|
|
||||||
|
If you need a helper value for ordering or automation:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "episode_next:<increment(#(episode),1)>"
|
||||||
|
```
|
||||||
|
|
||||||
|
or:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
add-tag "episode_prev:<increment(#(episode),-1)>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete A Derived Tag Predictably
|
||||||
|
|
||||||
|
Once a tag was created from a template, you can remove it with the same template:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
delete-tag "title:#(track) - #(series)"
|
||||||
|
delete-tag "code:s<padding(00,#(season))>e<padding(00,#(episode))>"
|
||||||
|
```
|
||||||
|
|
||||||
|
This is safer than manually typing the fully expanded value when doing bulk cleanup.
|
||||||
|
|
||||||
|
### Keep Inputs Lowercase Upstream
|
||||||
|
|
||||||
|
Because the documented system is lowercase-first, the cleanest workflows normalize source tags before using them in templates.
|
||||||
|
|
||||||
|
Recommended pattern:
|
||||||
|
|
||||||
|
- keep namespace names lowercase
|
||||||
|
- keep values lowercase when you create/import them
|
||||||
|
- use templates to compose values, not to fix letter casing later
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
series:ancient greek intensive course
|
||||||
|
episode:3
|
||||||
|
publisher:oxford
|
||||||
|
```
|
||||||
|
|
||||||
|
compose more predictably than mixed-case sources.
|
||||||
|
|
||||||
|
## Current Supported Syntax Summary
|
||||||
|
|
||||||
|
- `#(namespace)` inserts an existing tag value
|
||||||
|
- `#(track #)` compatibility aliasing works for namespaces that include a trailing `#`
|
||||||
|
- `<padding(width,value)>` zero-pads values
|
||||||
|
- `<pad(width,value)>` is an alias of `padding`
|
||||||
|
- `<zfill(width,value)>` is an alias of `padding`
|
||||||
|
- `<default(value,fallback)>` uses a fallback when the primary value is missing
|
||||||
|
- `<replace(value,old,new)>` performs plain substring replacement
|
||||||
|
- `<increment(value,amount)>` adds an integer offset, defaulting to `1`
|
||||||
|
|
||||||
|
If more transforms are added later, they should follow the same angle-bracket function style rather than introducing a second expression format.
|
||||||
+1
-1
@@ -57,4 +57,4 @@ Bundled walkthrough:
|
|||||||
- The walkthrough is in [docs/ftp_plugin_tutorial.md](docs/ftp_plugin_tutorial.md) and shows `search-file -plugin ftp -instance <name>`, folder drill-in via `@N`, file download routing, `@N | add-file -store ...`, and `add-file -plugin ftp -instance <name>` uploads.
|
- The 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 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 walkthrough is in [docs/scp_plugin_tutorial.md](docs/scp_plugin_tutorial.md) and shows `search-file -plugin scp -instance <name>`, SSH-backed directory drill-in, file download routing, `@N | add-file -store ...`, and `add-file -plugin scp -instance <name>` uploads.
|
||||||
- The repo now also includes a built-in HydrusNetwork provider in [plugins/hydrusnetwork/__init__.py](plugins/hydrusnetwork/__init__.py). Its Hydrus client API now lives alongside it in [plugins/hydrusnetwork/api.py](plugins/hydrusnetwork/api.py), while [API/HydrusNetwork.py](API/HydrusNetwork.py) remains a compatibility shim. The provider delegates to configured `store.hydrusnetwork.*` backends so Hydrus features can be reached through the normal plugin registry without cmdlets importing Hydrus modules directly.
|
- The repo now also includes a built-in HydrusNetwork provider in [plugins/hydrusnetwork/__init__.py](plugins/hydrusnetwork/__init__.py). Its Hydrus client API now lives in the plugin-owned package [plugins/hydrusnetwork/api/__init__.py](plugins/hydrusnetwork/api/__init__.py), its registry-facing store adapter lives in [plugins/hydrusnetwork/store_proxy.py](plugins/hydrusnetwork/store_proxy.py), and its heavy internal operations live in [plugins/hydrusnetwork/store_backend.py](plugins/hydrusnetwork/store_backend.py). This `plugins/<name>/api/` package shape is the intended pattern for plugin-owned API helpers going forward. The provider now resolves configured Hydrus instances directly from plugin config instead of routing back through `Store.registry`; the proxy exists only so generic store callers can still target configured Hydrus stores. [API/HydrusNetwork.py](API/HydrusNetwork.py) and [Store/HydrusNetwork.py](Store/HydrusNetwork.py) are legacy compatibility shims only, and store discovery prefers the plugin-owned Hydrus hook over those shims.
|
||||||
@@ -12,7 +12,7 @@ from typing import Any, Dict, Iterable, List, Optional, Callable, Tuple
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from API.HTTP import HTTPClient, _download_direct_file
|
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 ProviderCore.base import Provider, SearchResult
|
||||||
from SYS.provider_helpers import TableProviderMixin
|
from SYS.provider_helpers import TableProviderMixin
|
||||||
from SYS.item_accessors import get_field as _extract_value
|
from SYS.item_accessors import get_field as _extract_value
|
||||||
@@ -859,7 +859,7 @@ class AllDebrid(TableProviderMixin, Provider):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from API.alldebrid import AllDebridClient
|
from plugins.alldebrid.api import AllDebridClient
|
||||||
|
|
||||||
client = AllDebridClient(api_key)
|
client = AllDebridClient(api_key)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -1400,7 +1400,7 @@ class AllDebrid(TableProviderMixin, Provider):
|
|||||||
view = view or "folders"
|
view = view or "folders"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from API.alldebrid import AllDebridClient
|
from plugins.alldebrid.api import AllDebridClient
|
||||||
|
|
||||||
client = AllDebridClient(api_key)
|
client = AllDebridClient(api_key)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from typing import Any, Dict, Optional, Set, List, Sequence, Tuple
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from SYS.logger import log, debug
|
from SYS.logger import log, debug
|
||||||
from .HTTP import HTTPClient
|
from API.HTTP import HTTPClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from API.Tidal import (
|
from plugins.tidal.api import (
|
||||||
Tidal as TidalApiClient,
|
Tidal as TidalApiClient,
|
||||||
build_track_tags,
|
build_track_tags,
|
||||||
coerce_duration_seconds,
|
coerce_duration_seconds,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
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 ProviderCore.base import Provider, SearchResult
|
||||||
from SYS.cli_syntax import get_free_text, parse_query
|
from SYS.cli_syntax import get_free_text, parse_query
|
||||||
from SYS.logger import log
|
from SYS.logger import log
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from .base import API, ApiError
|
from API.base import API, ApiError
|
||||||
|
|
||||||
|
|
||||||
class LOCError(ApiError):
|
class LOCError(ApiError):
|
||||||
@@ -14,15 +14,15 @@ from SYS.logger import log, debug
|
|||||||
from SYS.result_table import Table
|
from SYS.result_table import Table
|
||||||
from SYS.item_accessors import get_sha256_hex
|
from SYS.item_accessors import get_sha256_hex
|
||||||
from SYS.utils import extract_hydrus_hash_from_url
|
from SYS.utils import extract_hydrus_hash_from_url
|
||||||
from SYS import pipeline as ctx
|
from SYS.command_parsing import (
|
||||||
from ProviderCore.registry import get_plugin, get_plugin_for_url
|
|
||||||
from cmdnat._parsing import (
|
|
||||||
extract_arg_value,
|
extract_arg_value,
|
||||||
extract_piped_value as _extract_piped_value,
|
extract_piped_value as _extract_piped_value,
|
||||||
extract_value_arg as _extract_value_arg,
|
extract_value_arg as _extract_value_arg,
|
||||||
has_flag as _has_flag,
|
has_flag as _has_flag,
|
||||||
normalize_to_list as _normalize_to_list,
|
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_ITEMS_KEY = "matrix_pending_items"
|
||||||
_MATRIX_PENDING_TEXT_KEY = "matrix_pending_text"
|
_MATRIX_PENDING_TEXT_KEY = "matrix_pending_text"
|
||||||
@@ -1297,3 +1297,5 @@ CMDLET = Cmdlet(
|
|||||||
],
|
],
|
||||||
exec=_run,
|
exec=_run,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
COMMANDS = [CMDLET]
|
||||||
@@ -15,7 +15,7 @@ try:
|
|||||||
from plugins.tidal import Tidal
|
from plugins.tidal import Tidal
|
||||||
except ImportError: # pragma: no cover - optional
|
except ImportError: # pragma: no cover - optional
|
||||||
Tidal = None
|
Tidal = None
|
||||||
from API.Tidal import (
|
from plugins.tidal.api import (
|
||||||
build_track_tags,
|
build_track_tags,
|
||||||
extract_artists,
|
extract_artists,
|
||||||
stringify,
|
stringify,
|
||||||
|
|||||||
@@ -396,6 +396,8 @@ function M._sync_uosc_cursor(reason)
|
|||||||
if ensure_uosc_loaded() then
|
if ensure_uosc_loaded() then
|
||||||
pcall(mp.commandv, 'script-message-to', 'uosc', 'sync-cursor')
|
pcall(mp.commandv, 'script-message-to', 'uosc', 'sync-cursor')
|
||||||
end
|
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_console', why .. suffix)
|
||||||
M._disable_input_section('input_forced_console', why .. suffix)
|
M._disable_input_section('input_forced_console', why .. suffix)
|
||||||
M._disable_input_section('image', why .. suffix)
|
M._disable_input_section('image', why .. suffix)
|
||||||
@@ -410,6 +412,19 @@ function M._sync_uosc_cursor(reason)
|
|||||||
end)
|
end)
|
||||||
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)
|
function M._close_uosc_menu_and_sync(menu_type, reason)
|
||||||
local why = tostring(reason or 'unknown')
|
local why = tostring(reason or 'unknown')
|
||||||
if ensure_uosc_loaded() then
|
if ensure_uosc_loaded() then
|
||||||
@@ -425,6 +440,8 @@ end
|
|||||||
|
|
||||||
M._reset_uosc_input_state = function(reason)
|
M._reset_uosc_input_state = function(reason)
|
||||||
local why = tostring(reason or 'unknown')
|
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_console', why)
|
||||||
M._disable_input_section('input_forced_console', why)
|
M._disable_input_section('input_forced_console', why)
|
||||||
M._disable_input_section('image', why)
|
M._disable_input_section('image', why)
|
||||||
@@ -529,7 +546,7 @@ local function trim(s)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Lyrics overlay toggle
|
-- 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 LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible"
|
||||||
|
|
||||||
local function lyric_get_visible()
|
local function lyric_get_visible()
|
||||||
@@ -679,7 +696,11 @@ end
|
|||||||
local function _detect_format_probe_script()
|
local function _detect_format_probe_script()
|
||||||
local repo_root = _detect_repo_root()
|
local repo_root = _detect_repo_root()
|
||||||
if repo_root ~= '' then
|
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
|
if _path_exists(direct) then
|
||||||
return direct
|
return direct
|
||||||
end
|
end
|
||||||
@@ -690,6 +711,9 @@ local function _detect_format_probe_script()
|
|||||||
local source_dir = _get_lua_source_path():match('(.*)[/\\]') or ''
|
local source_dir = _get_lua_source_path():match('(.*)[/\\]') or ''
|
||||||
local script_dir = mp.get_script_directory() or ''
|
local script_dir = mp.get_script_directory() or ''
|
||||||
local cwd = utils.getcwd() 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(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(script_dir, 'MPV/format_probe.py', 8))
|
||||||
_append_unique_path(candidates, seen, find_file_upwards(cwd, '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 .. '/' .. file_name)
|
||||||
_append_unique_path(candidates, seen, script_dir .. '/LUA/' .. 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, 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))
|
_append_unique_path(candidates, seen, find_file_upwards(script_dir, 'MPV/LUA/' .. file_name, 8))
|
||||||
end
|
end
|
||||||
|
|
||||||
if cwd ~= '' then
|
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))
|
_append_unique_path(candidates, seen, find_file_upwards(cwd, 'MPV/LUA/' .. file_name, 8))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1412,10 +1438,16 @@ local function attempt_start_pipeline_helper_async(callback)
|
|||||||
local helper_script = ''
|
local helper_script = ''
|
||||||
local repo_root = _detect_repo_root()
|
local repo_root = _detect_repo_root()
|
||||||
if repo_root ~= '' then
|
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
|
if _path_exists(direct) then
|
||||||
helper_script = direct
|
helper_script = direct
|
||||||
end
|
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
|
end
|
||||||
if helper_script == '' then
|
if helper_script == '' then
|
||||||
local candidates = {}
|
local candidates = {}
|
||||||
@@ -1423,6 +1455,9 @@ local function attempt_start_pipeline_helper_async(callback)
|
|||||||
local source_dir = _get_lua_source_path():match('(.*)[/\\]') or ''
|
local source_dir = _get_lua_source_path():match('(.*)[/\\]') or ''
|
||||||
local script_dir = mp.get_script_directory() or ''
|
local script_dir = mp.get_script_directory() or ''
|
||||||
local cwd = utils.getcwd() 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(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(script_dir, 'MPV/pipeline_helper.py', 8))
|
||||||
_append_unique_path(candidates, seen, find_file_upwards(cwd, '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
|
local launch_root = repo_root
|
||||||
if launch_root == '' then
|
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
|
end
|
||||||
|
|
||||||
local bootstrap = table.concat({
|
local bootstrap = table.concat({
|
||||||
@@ -1528,7 +1563,7 @@ function M._resolve_repo_script(relative_path)
|
|||||||
|
|
||||||
for _, candidate in ipairs(candidates) do
|
for _, candidate in ipairs(candidates) do
|
||||||
if _path_exists(candidate) then
|
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
|
return candidate, launch_root
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1561,9 +1596,12 @@ function M._attempt_start_lyric_helper_async(reason)
|
|||||||
return false
|
return false
|
||||||
end
|
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
|
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
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2852,9 +2890,7 @@ local function _commit_pending_screenshot(tags)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function _apply_screenshot_tag_query(query)
|
local function _apply_screenshot_tag_query(query)
|
||||||
pcall(function()
|
M._close_uosc_menu_and_sync(SCREENSHOT_TAG_MENU_TYPE, 'screenshot-tags-submit')
|
||||||
mp.commandv('script-message-to', 'uosc', 'close-menu', SCREENSHOT_TAG_MENU_TYPE)
|
|
||||||
end)
|
|
||||||
_commit_pending_screenshot(_normalize_tag_list(query))
|
_commit_pending_screenshot(_normalize_tag_list(query))
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
local function _open_store_picker_for_pending_screenshot()
|
local function _open_store_picker_for_pending_screenshot()
|
||||||
@@ -3320,8 +3358,8 @@ local function _open_sleep_timer_prompt()
|
|||||||
items = items,
|
items = items,
|
||||||
}
|
}
|
||||||
|
|
||||||
if ensure_uosc_loaded() then
|
if M._open_uosc_menu(menu_data, 'sleep-timer-prompt') then
|
||||||
mp.commandv('script-message-to', 'uosc', 'open-menu', utils.format_json(menu_data))
|
return
|
||||||
else
|
else
|
||||||
mp.osd_message('Sleep timer unavailable (uosc not loaded)', 2.0)
|
mp.osd_message('Sleep timer unavailable (uosc not loaded)', 2.0)
|
||||||
end
|
end
|
||||||
@@ -3336,9 +3374,7 @@ local function _apply_sleep_timer_query(query)
|
|||||||
|
|
||||||
if minutes <= 0 then
|
if minutes <= 0 then
|
||||||
_cancel_sleep_timer(true)
|
_cancel_sleep_timer(true)
|
||||||
pcall(function()
|
M._close_uosc_menu_and_sync(SLEEP_PROMPT_MENU_TYPE, 'sleep-timer-cancel')
|
||||||
mp.commandv('script-message-to', 'uosc', 'close-menu', SLEEP_PROMPT_MENU_TYPE)
|
|
||||||
end)
|
|
||||||
return
|
return
|
||||||
end
|
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)
|
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))
|
_lua_log('sleep: timer set minutes=' .. tostring(minutes) .. ' seconds=' .. tostring(seconds))
|
||||||
|
|
||||||
pcall(function()
|
M._close_uosc_menu_and_sync(SLEEP_PROMPT_MENU_TYPE, 'sleep-timer-submit')
|
||||||
mp.commandv('script-message-to', 'uosc', 'close-menu', SLEEP_PROMPT_MENU_TYPE)
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function _handle_sleep_timer_event(json)
|
local function _handle_sleep_timer_event(json)
|
||||||
@@ -3445,6 +3479,7 @@ end
|
|||||||
local function _deactivate_image_controls()
|
local function _deactivate_image_controls()
|
||||||
if not ImageControl.enabled then
|
if not ImageControl.enabled then
|
||||||
_disable_image_section()
|
_disable_image_section()
|
||||||
|
M._schedule_uosc_cursor_resync('image-controls-disabled-idle')
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
ImageControl.enabled = false
|
ImageControl.enabled = false
|
||||||
@@ -3459,6 +3494,7 @@ local function _deactivate_image_controls()
|
|||||||
mp.set_property_number('video-pan-y', 0)
|
mp.set_property_number('video-pan-y', 0)
|
||||||
mp.set_property('video-align-x', '0')
|
mp.set_property('video-align-x', '0')
|
||||||
mp.set_property('video-align-y', '0')
|
mp.set_property('video-align-y', '0')
|
||||||
|
M._schedule_uosc_cursor_resync('image-controls-disabled')
|
||||||
end
|
end
|
||||||
|
|
||||||
local function _update_image_mode()
|
local function _update_image_mode()
|
||||||
@@ -3472,6 +3508,9 @@ end
|
|||||||
|
|
||||||
mp.register_event('file-loaded', function()
|
mp.register_event('file-loaded', function()
|
||||||
_update_image_mode()
|
_update_image_mode()
|
||||||
|
if not _get_current_item_is_image() then
|
||||||
|
M._schedule_uosc_cursor_resync('file-loaded')
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
mp.register_event('shutdown', function()
|
mp.register_event('shutdown', function()
|
||||||
@@ -5709,6 +5748,24 @@ mp.observe_property('track-list', 'native', function()
|
|||||||
M._ensure_current_subtitles_visible('observe-track-list')
|
M._ensure_current_subtitles_visible('observe-track-list')
|
||||||
end)
|
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)
|
mp.observe_property('ytdl-raw-info', 'native', function(_name, value)
|
||||||
if type(value) ~= 'table' then
|
if type(value) ~= 'table' then
|
||||||
return
|
return
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from plugins.mpv.mpv_ipc import MPV
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MPV",
|
||||||
|
]
|
||||||
@@ -10,10 +10,10 @@ from datetime import datetime, timedelta
|
|||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg, parse_cmdlet_args
|
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.logger import debug, get_thread_stream, is_debug_enabled, set_debug, set_thread_stream
|
||||||
from SYS.result_table import Table
|
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 import pipeline as ctx
|
||||||
from SYS.models import PipeObject
|
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($|\?)")
|
_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:
|
def _repo_root() -> Path:
|
||||||
try:
|
try:
|
||||||
return Path(__file__).resolve().parent.parent
|
return Path(__file__).resolve().parent.parent
|
||||||
@@ -564,6 +557,111 @@ def _resolve_plugin_playback_path(item: Any, config: Optional[Dict[str, Any]]) -
|
|||||||
return None
|
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:
|
def _ensure_lyric_overlay(mpv: MPV) -> None:
|
||||||
try:
|
try:
|
||||||
mpv.ensure_lyric_loader_running()
|
mpv.ensure_lyric_loader_running()
|
||||||
@@ -638,29 +736,17 @@ def _extract_store_and_hash(
|
|||||||
except Exception:
|
except Exception:
|
||||||
file_hash = None
|
file_hash = None
|
||||||
|
|
||||||
hydrus_provider = _get_hydrus_provider(config)
|
store, file_hash = _resolve_provider_item_context(
|
||||||
if hydrus_provider is not None:
|
item,
|
||||||
normalized_store = None
|
metadata=metadata if isinstance(metadata, dict) else None,
|
||||||
try:
|
store=store,
|
||||||
if store and hydrus_provider.is_store_name(store):
|
file_hash=file_hash,
|
||||||
normalized_store = store
|
targets=targets,
|
||||||
except Exception:
|
config=config,
|
||||||
normalized_store = None
|
)
|
||||||
|
|
||||||
for target in targets:
|
if store and store.upper() in {"PATH", "LOCAL", "UNKNOWN"}:
|
||||||
try:
|
store = None
|
||||||
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 not file_hash:
|
if not file_hash:
|
||||||
try:
|
try:
|
||||||
@@ -725,7 +811,7 @@ def _prefetch_notes_async(
|
|||||||
|
|
||||||
def _worker() -> None:
|
def _worker() -> None:
|
||||||
try:
|
try:
|
||||||
from MPV.lyric import (
|
from plugins.mpv.lyric import (
|
||||||
load_cached_notes,
|
load_cached_notes,
|
||||||
set_notes_prefetch_pending,
|
set_notes_prefetch_pending,
|
||||||
store_cached_notes,
|
store_cached_notes,
|
||||||
@@ -754,7 +840,7 @@ def _prefetch_notes_async(
|
|||||||
debug(f"MPV note prefetch failed for {key}: {exc}", file=sys.stderr)
|
debug(f"MPV note prefetch failed for {key}: {exc}", file=sys.stderr)
|
||||||
finally:
|
finally:
|
||||||
try:
|
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)
|
set_notes_prefetch_pending(store, file_hash, False)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -849,62 +935,6 @@ def _extract_target_from_memory_uri(text: str) -> Optional[str]:
|
|||||||
return None
|
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]:
|
def _normalize_playlist_path(text: Optional[str]) -> Optional[str]:
|
||||||
"""Normalize playlist entry paths for dedupe comparisons."""
|
"""Normalize playlist entry paths for dedupe comparisons."""
|
||||||
if not text:
|
if not text:
|
||||||
@@ -960,29 +990,18 @@ def _infer_store_from_playlist_item(
|
|||||||
if memory_target:
|
if memory_target:
|
||||||
target = memory_target
|
target = memory_target
|
||||||
|
|
||||||
# Hydrus hashes: bare 64-hex entries
|
provider_store = _infer_provider_playlist_store(
|
||||||
if _SHA256_FULL_RE.fullmatch(target.lower()):
|
item,
|
||||||
# If we have file_storage, query each Hydrus instance to find which one has this hash
|
target=target,
|
||||||
if file_storage:
|
file_storage=file_storage,
|
||||||
hash_str = target.lower()
|
config=config,
|
||||||
hydrus_instance = _find_hydrus_instance_for_hash(hash_str, file_storage, config=config)
|
)
|
||||||
if hydrus_instance:
|
if provider_store:
|
||||||
return hydrus_instance
|
return provider_store
|
||||||
return "hydrus"
|
|
||||||
|
|
||||||
lower = target.lower()
|
lower = target.lower()
|
||||||
if lower.startswith("magnet:"):
|
if lower.startswith("magnet:"):
|
||||||
return "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
|
# Windows / UNC paths
|
||||||
if _WINDOWS_PATH_RE.match(target) or target.startswith("\\\\"):
|
if _WINDOWS_PATH_RE.match(target) or target.startswith("\\\\"):
|
||||||
@@ -1010,35 +1029,6 @@ def _infer_store_from_playlist_item(
|
|||||||
return "soundcloud"
|
return "soundcloud"
|
||||||
if "bandcamp" in host_stripped:
|
if "bandcamp" in host_stripped:
|
||||||
return "bandcamp"
|
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(".")
|
parts = host_stripped.split(".")
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
@@ -1444,29 +1434,27 @@ def _get_playable_path(
|
|||||||
# - MPV IPC pipe (transport)
|
# - MPV IPC pipe (transport)
|
||||||
# - PipeObject (pipeline data)
|
# - PipeObject (pipeline data)
|
||||||
backend_target_resolved = False
|
backend_target_resolved = False
|
||||||
hydrus_provider = _get_hydrus_provider(config)
|
if store and file_hash and file_hash != "unknown":
|
||||||
if store and file_hash and file_hash != "unknown" and file_storage:
|
|
||||||
try:
|
try:
|
||||||
backend = file_storage[store]
|
resolved_path = _resolve_plugin_playback_path(
|
||||||
except Exception:
|
{
|
||||||
backend = None
|
"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
|
backend_target_resolved = True
|
||||||
|
path = resolved_path
|
||||||
# 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
|
|
||||||
|
|
||||||
if isinstance(path, str) and path.startswith(("http://", "https://")) and not backend_target_resolved:
|
if isinstance(path, str) and path.startswith(("http://", "https://")) and not backend_target_resolved:
|
||||||
return (path, title)
|
return (path, title)
|
||||||
@@ -1735,7 +1723,7 @@ def _queue_items(
|
|||||||
"request_id":
|
"request_id":
|
||||||
199,
|
199,
|
||||||
}
|
}
|
||||||
_send_ipc_command(header_cmd, silent=True, wait=False)
|
_send_ipc_command(header_cmd, silent=True, wait=True)
|
||||||
if effective_ytdl_opts:
|
if effective_ytdl_opts:
|
||||||
ytdl_cmd = {
|
ytdl_cmd = {
|
||||||
"command":
|
"command":
|
||||||
@@ -1744,7 +1732,7 @@ def _queue_items(
|
|||||||
effective_ytdl_opts],
|
effective_ytdl_opts],
|
||||||
"request_id": 197,
|
"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
|
# 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.
|
# 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,
|
) if isinstance(playlist_after,
|
||||||
list) else 0
|
list) else 0
|
||||||
|
|
||||||
should_autoplay = False
|
idx_to_play: Optional[int] = None
|
||||||
if idle_before is True:
|
if after_len > before_len:
|
||||||
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 = min(max(0, before_len), after_len - 1)
|
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.
|
# Prefer the store/hash from the piped item when auto-playing.
|
||||||
try:
|
try:
|
||||||
s, h = _extract_store_and_hash(items_to_add[0], config=config)
|
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)
|
items = _get_playlist(silent=True)
|
||||||
|
|
||||||
if items is None:
|
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:
|
if mpv_started:
|
||||||
# MPV was just started, retry getting playlist after a brief delay
|
# MPV was just started, retry getting playlist after a brief delay
|
||||||
import time
|
import time
|
||||||
@@ -2288,6 +2282,10 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
debug("MPV is starting up...")
|
debug("MPV is starting up...")
|
||||||
return 0
|
return 0
|
||||||
else:
|
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
|
# Do not auto-launch MPV when no action/inputs were provided; avoid surprise startups
|
||||||
no_inputs = not any(
|
no_inputs = not any(
|
||||||
[
|
[
|
||||||
@@ -2548,6 +2546,13 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
helper_status = "not running"
|
helper_status = "not running"
|
||||||
if helper_heartbeat not in (None, "", "0", False):
|
if helper_heartbeat not in (None, "", "0", False):
|
||||||
helper_status = f"running ({helper_heartbeat})"
|
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}")
|
print(f"Pipeline helper: {helper_status}")
|
||||||
|
|
||||||
@@ -2823,3 +2828,5 @@ CMDLET = Cmdlet(
|
|||||||
],
|
],
|
||||||
exec=_run,
|
exec=_run,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
COMMANDS = [CMDLET]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
def _repo_root() -> Path:
|
||||||
|
package_dir = Path(__file__).resolve().parent
|
||||||
|
if package_dir.name.lower() == "mpv" and package_dir.parent.name.lower() == "plugins":
|
||||||
|
return package_dir.parent.parent
|
||||||
|
return package_dir.parent
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = _repo_root()
|
||||||
|
if str(REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
if not args:
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"success": False,
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "",
|
||||||
|
"error": "Missing url",
|
||||||
|
"table": None,
|
||||||
|
}
|
||||||
|
print(json.dumps(payload, ensure_ascii=False))
|
||||||
|
return 2
|
||||||
|
|
||||||
|
url = str(args[0] or "").strip()
|
||||||
|
captured_stdout = io.StringIO()
|
||||||
|
captured_stderr = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr):
|
||||||
|
from plugins.mpv.pipeline_helper import _run_op
|
||||||
|
|
||||||
|
payload = _run_op("ytdlp-formats", {"url": url})
|
||||||
|
|
||||||
|
noisy_stdout = captured_stdout.getvalue().strip()
|
||||||
|
noisy_stderr = captured_stderr.getvalue().strip()
|
||||||
|
if noisy_stdout:
|
||||||
|
payload["stdout"] = "\n".join(filter(None, [str(payload.get("stdout") or "").strip(), noisy_stdout]))
|
||||||
|
if noisy_stderr:
|
||||||
|
payload["stderr"] = "\n".join(filter(None, [str(payload.get("stderr") or "").strip(), noisy_stderr]))
|
||||||
|
|
||||||
|
print(json.dumps(payload, ensure_ascii=False))
|
||||||
|
return 0 if payload.get("success") else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ audio-buffer=2.0
|
|||||||
|
|
||||||
# Ensure uosc texture/icon fonts are discoverable by libass.
|
# Ensure uosc texture/icon fonts are discoverable by libass.
|
||||||
osd-fonts-dir=~~/scripts/uosc/fonts
|
osd-fonts-dir=~~/scripts/uosc/fonts
|
||||||
sub-fonts-dir=~~/scripts/uosc/
|
sub-fonts-dir=~~/scripts/uosc/fonts
|
||||||
|
|
||||||
ontop=yes
|
ontop=yes
|
||||||
autofit=45%
|
autofit=45%
|
||||||
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -193,7 +193,7 @@ class PodcastIndex(Provider):
|
|||||||
feed_url = str(feed_md.get("url") or item0.get("path") or "").strip()
|
feed_url = str(feed_md.get("url") or item0.get("path") or "").strip()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from API.podcastindex import PodcastIndexClient
|
from plugins.podcastindex.api import PodcastIndexClient
|
||||||
|
|
||||||
client = PodcastIndexClient(key, secret)
|
client = PodcastIndexClient(key, secret)
|
||||||
if feed_id:
|
if feed_id:
|
||||||
@@ -407,7 +407,7 @@ class PodcastIndex(Provider):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from API.podcastindex import PodcastIndexClient
|
from plugins.podcastindex.api import PodcastIndexClient
|
||||||
|
|
||||||
client = PodcastIndexClient(key, secret)
|
client = PodcastIndexClient(key, secret)
|
||||||
feeds = client.search_byterm(query, max_results=limit)
|
feeds = client.search_byterm(query, max_results=limit)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import hashlib
|
|||||||
import time
|
import time
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from .base import API, ApiError
|
from API.base import API, ApiError
|
||||||
|
|
||||||
|
|
||||||
class PodcastIndexError(ApiError):
|
class PodcastIndexError(ApiError):
|
||||||
@@ -5,11 +5,11 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, List, Optional, Sequence
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
|
|
||||||
from SYS.cmdlet_spec import Cmdlet, CmdletArg
|
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.logger import log
|
||||||
from SYS.result_table import Table
|
from SYS.result_table import Table
|
||||||
from SYS import pipeline as ctx
|
from SYS import pipeline as ctx
|
||||||
from ProviderCore.registry import get_plugin
|
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"
|
_TELEGRAM_PENDING_ITEMS_KEY = "telegram_pending_items"
|
||||||
|
|
||||||
@@ -339,3 +339,5 @@ CMDLET = Cmdlet(
|
|||||||
],
|
],
|
||||||
exec=_run,
|
exec=_run,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
COMMANDS = [CMDLET]
|
||||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from API.Tidal import (
|
from plugins.tidal.api import (
|
||||||
Tidal as TidalApiClient,
|
Tidal as TidalApiClient,
|
||||||
build_track_tags,
|
build_track_tags,
|
||||||
coerce_duration_seconds,
|
coerce_duration_seconds,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any, Dict, List, Optional, Set
|
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
|
from SYS.logger import debug, debug_panel
|
||||||
|
|
||||||
DEFAULT_BASE_URL = "https://tidal-api.binimum.org"
|
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
|
# 1. Fetch info (metadata) - fetch raw to ensure all fields are available for merging
|
||||||
info_resp = self._get_json("info/", params={"id": track_int})
|
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
|
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:
|
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 {}
|
info_data = info_resp if isinstance(info_resp, dict) and "id" in info_resp else {}
|
||||||
|
|
||||||
# 2. Fetch track (manifest/bit depth)
|
# 2. Fetch track (manifest/bit depth)
|
||||||
track_resp = self.track(track_id)
|
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.
|
# 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
|
track_data = track_resp.get("data") if isinstance(track_resp, dict) else track_resp
|
||||||
if not isinstance(track_data, dict):
|
if not isinstance(track_data, dict):
|
||||||
@@ -285,7 +285,7 @@ class Tidal(API):
|
|||||||
lyrics_data = {}
|
lyrics_data = {}
|
||||||
try:
|
try:
|
||||||
lyr_resp = self.lyrics(track_id)
|
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 {}
|
lyrics_data = lyr_resp.get("lyrics") or lyr_resp if isinstance(lyr_resp, dict) else {}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -309,7 +309,7 @@ class Tidal(API):
|
|||||||
"lyrics": lyrics_data,
|
"lyrics": lyrics_data,
|
||||||
}
|
}
|
||||||
debug_panel(
|
debug_panel(
|
||||||
"API.Tidal full track metadata",
|
"plugins.tidal.api full track metadata",
|
||||||
[
|
[
|
||||||
("track_id", track_int),
|
("track_id", track_int),
|
||||||
("metadata_keys", len(merged_md)),
|
("metadata_keys", len(merged_md)),
|
||||||
@@ -20,6 +20,7 @@ Medios-Macina is a API driven file media manager and virtual toolbox capable of
|
|||||||
<h2>CONTENTS</H2>
|
<h2>CONTENTS</H2>
|
||||||
<a href="#features">FEATURES</a><br>
|
<a href="#features">FEATURES</a><br>
|
||||||
<a href="#installation">INSTALLATION</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/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/Hydrus-Network">HYDRUS NETWORK</a><br>
|
||||||
<a href="https://code.glowers.club/goyimnose/Medios-Macina/wiki/cookies.txt">COOKIES</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>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><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><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>
|
||||||
</ul
|
</ul
|
||||||
|
|
||||||
|
|||||||
@@ -1057,6 +1057,7 @@ def main() -> int:
|
|||||||
def _ensure_repo_available() -> bool:
|
def _ensure_repo_available() -> bool:
|
||||||
"""Prompt for a clone location when running outside the repository."""
|
"""Prompt for a clone location when running outside the repository."""
|
||||||
nonlocal repo_root, script_dir, is_in_repo
|
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 we have already settled on a repository path in this session, skip.
|
||||||
if is_in_repo and repo_root is not None:
|
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)
|
print("Error: Could not determine installation path.", file=sys.stderr)
|
||||||
return False
|
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):
|
except (EOFError, KeyboardInterrupt):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user