huge refactor of the entire codebase, with the goal of improving maintainability, readability, and extensibility. This commit includes changes to almost every file in the project, including:

This commit is contained in:
2026-04-19 00:41:09 -07:00
parent d9e736172a
commit bafd37fdfb
50 changed files with 3258 additions and 4177 deletions
+5 -5
View File
@@ -37,7 +37,7 @@
"(rapidgator\\.net/file/[0-9]{7,8})" "(rapidgator\\.net/file/[0-9]{7,8})"
], ],
"regexp": "((rapidgator\\.net|rg\\.to|rapidgator\\.asia)/file/([0-9a-zA-Z]{32}))|((rapidgator\\.net/file/[0-9]{7,8}))", "regexp": "((rapidgator\\.net|rg\\.to|rapidgator\\.asia)/file/([0-9a-zA-Z]{32}))|((rapidgator\\.net/file/[0-9]{7,8}))",
"status": true "status": false
}, },
"turbobit": { "turbobit": {
"name": "turbobit", "name": "turbobit",
@@ -425,7 +425,7 @@
"(hexupload\\.net|hexload\\.com)/([a-zA-Z0-9]{12})" "(hexupload\\.net|hexload\\.com)/([a-zA-Z0-9]{12})"
], ],
"regexp": "(hexupload\\.net|hexload\\.com)/([a-zA-Z0-9]{12})", "regexp": "(hexupload\\.net|hexload\\.com)/([a-zA-Z0-9]{12})",
"status": false "status": true
}, },
"hot4share": { "hot4share": {
"name": "hot4share", "name": "hot4share",
@@ -482,7 +482,7 @@
"(katfile\\.com/[0-9a-zA-Z]{12})" "(katfile\\.com/[0-9a-zA-Z]{12})"
], ],
"regexp": "(katfile\\.(cloud|online|vip)/([0-9a-zA-Z]{12}))|((katfile\\.com/[0-9a-zA-Z]{12}))", "regexp": "(katfile\\.(cloud|online|vip)/([0-9a-zA-Z]{12}))|((katfile\\.com/[0-9a-zA-Z]{12}))",
"status": false "status": true
}, },
"mediafire": { "mediafire": {
"name": "mediafire", "name": "mediafire",
@@ -595,7 +595,7 @@
"(simfileshare\\.net/download/[0-9]+/)" "(simfileshare\\.net/download/[0-9]+/)"
], ],
"regexp": "(simfileshare\\.net/download/[0-9]+/)", "regexp": "(simfileshare\\.net/download/[0-9]+/)",
"status": false "status": true
}, },
"streamtape": { "streamtape": {
"name": "streamtape", "name": "streamtape",
@@ -690,7 +690,7 @@
"uploadrar\\.(net|com)/([0-9a-z]{12})" "uploadrar\\.(net|com)/([0-9a-z]{12})"
], ],
"regexp": "((get|cloud)\\.rahim-soft\\.com/([0-9a-z]{12}))|((fingau\\.com/([0-9a-z]{12})))|((tech|miui|cloud|flash)\\.getpczone\\.com/([0-9a-z]{12}))|(miui.rahim-soft\\.com/([0-9a-z]{12}))|(uploadrar\\.(net|com)/([0-9a-z]{12}))", "regexp": "((get|cloud)\\.rahim-soft\\.com/([0-9a-z]{12}))|((fingau\\.com/([0-9a-z]{12})))|((tech|miui|cloud|flash)\\.getpczone\\.com/([0-9a-z]{12}))|(miui.rahim-soft\\.com/([0-9a-z]{12}))|(uploadrar\\.(net|com)/([0-9a-z]{12}))",
"status": false, "status": true,
"hardRedirect": [ "hardRedirect": [
"uploadrar.com/([0-9a-zA-Z]{12})" "uploadrar.com/([0-9a-zA-Z]{12})"
] ]
+21 -202
View File
@@ -59,13 +59,9 @@ from SYS.rich_display import (
) )
from cmdnat._status_shared import ( from cmdnat._status_shared import (
add_startup_check as _shared_add_startup_check, add_startup_check as _shared_add_startup_check,
default_provider_ping_targets as _default_provider_ping_targets, collect_plugin_startup_checks as _collect_plugin_startup_checks,
has_provider as _has_provider,
has_store_subtype as _has_store_subtype, has_store_subtype as _has_store_subtype,
has_tool as _has_tool, has_tool as _has_tool,
ping_first as _ping_first,
ping_url as _ping_url,
provider_display_name as _provider_display_name,
) )
@@ -98,12 +94,12 @@ from SYS.cmdlet_catalog import (
list_cmdlet_metadata, list_cmdlet_metadata,
list_cmdlet_names, list_cmdlet_names,
) )
from SYS.config import load_config from SYS.config import load_config, resolve_cookies_path
from SYS.result_table import Table from SYS.result_table import Table
from SYS.worker import WorkerManagerRegistry, WorkerStages, WorkerOutputMirror, WorkerStageSession from SYS.worker import WorkerManagerRegistry, WorkerStages, WorkerOutputMirror, WorkerStageSession
from SYS.pipeline import PipelineExecutor from SYS.pipeline import PipelineExecutor
from ProviderCore.registry import provider_inline_query_choices from ProviderCore.registry import plugin_inline_query_choices
@@ -507,25 +503,25 @@ class CmdletIntrospection:
if backends: if backends:
return backends return backends
if normalized_arg == "provider": if normalized_arg == "plugin":
canonical_cmd = (cmd_name or "").replace("_", "-").lower() canonical_cmd = (cmd_name or "").replace("_", "-").lower()
try: try:
from ProviderCore.registry import list_search_providers, list_file_providers from ProviderCore.registry import list_search_plugins, list_upload_plugins
except Exception: except Exception:
list_search_providers = None # type: ignore list_search_plugins = None # type: ignore
list_file_providers = None # type: ignore list_upload_plugins = None # type: ignore
provider_choices: List[str] = [] provider_choices: List[str] = []
if canonical_cmd in {"add-file"} and list_file_providers is not None: if canonical_cmd in {"add-file"} and list_upload_plugins is not None:
providers = list_file_providers(config) or {} providers = list_upload_plugins(config) or {}
available = [ available = [
name for name, is_ready in providers.items() if is_ready name for name, is_ready in providers.items() if is_ready
] ]
return sorted(available) if available else sorted(providers.keys()) return sorted(available) if available else sorted(providers.keys())
if list_search_providers is not None: if list_search_plugins is not None:
providers = list_search_providers(config) or {} providers = list_search_plugins(config) or {}
available = [ available = [
name for name, is_ready in providers.items() if is_ready name for name, is_ready in providers.items() if is_ready
] ]
@@ -680,7 +676,7 @@ class CmdletCompleter(Completer):
provider_name = None provider_name = None
if cmd_name == "search-file": if cmd_name == "search-file":
provider_name = self._flag_value(stage_tokens, "-provider", "--provider") provider_name = self._flag_value(stage_tokens, "-plugin", "--plugin")
if ( if (
cmd_name == "search-file" cmd_name == "search-file"
@@ -705,7 +701,7 @@ class CmdletCompleter(Completer):
field, partial = inline_token.split(":", 1) field, partial = inline_token.split(":", 1)
field = field.strip().lower() field = field.strip().lower()
partial_lower = partial.strip().lower() partial_lower = partial.strip().lower()
inline_choices = provider_inline_query_choices(provider_name, field, config) inline_choices = plugin_inline_query_choices(provider_name, field, config)
if inline_choices: if inline_choices:
filtered = ( filtered = (
[c for c in inline_choices if partial_lower in str(c).lower()] [c for c in inline_choices if partial_lower in str(c).lower()]
@@ -728,7 +724,7 @@ class CmdletCompleter(Completer):
if choices: if choices:
choice_list = choices choice_list = choices
normalized_prev = prev_token.lstrip("-").strip().lower() normalized_prev = prev_token.lstrip("-").strip().lower()
if normalized_prev == "provider" and current_token: if normalized_prev in {"plugin", "provider"} and current_token:
current_lower = current_token.lower() current_lower = current_token.lower()
filtered = [c for c in choices if current_lower in c.lower()] filtered = [c for c in choices if current_lower in c.lower()]
if filtered: if filtered:
@@ -1996,188 +1992,13 @@ Come to love it when others take what you share, as there is no greater joy
) if isinstance(config, ) if isinstance(config,
dict) else None dict) else None
if isinstance(provider_cfg, dict) and provider_cfg: if isinstance(provider_cfg, dict) and provider_cfg:
from Provider.metadata_provider import list_metadata_providers for check in _collect_plugin_startup_checks(config):
from ProviderCore.registry import (
list_file_providers,
list_providers,
list_search_providers,
)
provider_availability = list_providers(config) or {}
search_availability = list_search_providers(config) or {}
file_availability = list_file_providers(config) or {}
meta_availability = list_metadata_providers(config) or {}
already_checked = {"matrix"}
for provider_name in provider_cfg.keys():
prov = str(provider_name or "").strip().lower()
if not prov or prov in already_checked:
continue
display = _provider_display_name(prov)
if prov == "alldebrid":
try:
from Provider.alldebrid import _get_debrid_api_key
from API.alldebrid import AllDebridClient
api_key = _get_debrid_api_key(config)
if not api_key:
_add_startup_check(
"DISABLED",
display,
provider=prov,
detail="Not configured"
)
else:
client = AllDebridClient(api_key)
base_url = str(
getattr(client,
"base_url",
"") or ""
).strip()
_add_startup_check(
"ENABLED",
display,
provider=prov,
detail=base_url or "Connected",
)
except Exception as exc:
_add_startup_check(
"DISABLED",
display,
provider=prov,
detail=str(exc)
)
continue
is_known = False
ok_val: Optional[bool] = None
if prov in provider_availability:
is_known = True
ok_val = bool(provider_availability.get(prov))
elif prov in search_availability:
is_known = True
ok_val = bool(search_availability.get(prov))
elif prov in file_availability:
is_known = True
ok_val = bool(file_availability.get(prov))
elif prov in meta_availability:
is_known = True
ok_val = bool(meta_availability.get(prov))
if not is_known:
_add_startup_check(
"UNKNOWN",
display,
provider=prov,
detail="Not registered"
)
else:
detail = "Configured" if ok_val else "Not configured"
ping_targets = _default_provider_ping_targets(prov)
if ping_targets:
ping_ok, ping_detail = _ping_first(ping_targets)
if ok_val:
detail = ping_detail
else:
detail = (
(detail + " | " +
ping_detail) if ping_detail else detail
)
_add_startup_check(
"ENABLED" if ok_val else "DISABLED",
display,
provider=prov,
detail=detail,
)
already_checked.add(prov)
default_search_providers = [
"openlibrary",
"libgen",
"youtube",
"bandcamp"
]
for prov in default_search_providers:
if prov in already_checked:
continue
display = _provider_display_name(prov)
ok_val = (
bool(search_availability.get(prov))
if prov in search_availability else False
)
ping_targets = _default_provider_ping_targets(prov)
ping_ok, ping_detail = (
_ping_first(ping_targets) if ping_targets else (False, "No ping target")
)
detail = ping_detail or (
"Available" if ok_val else "Unavailable"
)
if not ok_val:
detail = "Unavailable" + (
f" | {ping_detail}" if ping_detail else ""
)
_add_startup_check( _add_startup_check(
"ENABLED" if (ok_val and ping_ok) else "DISABLED", str(check.get("status") or "UNKNOWN"),
display, str(check.get("name") or "Plugin"),
provider=prov, provider=str(check.get("plugin") or ""),
detail=detail, detail=str(check.get("detail") or ""),
) files=check.get("files"),
already_checked.add(prov)
if "0x0" not in already_checked:
ok_val = (
bool(file_availability.get("0x0"))
if "0x0" in file_availability else False
)
ping_ok, ping_detail = _ping_url("https://0x0.st")
detail = ping_detail
if not ok_val:
detail = "Unavailable" + (
f" | {ping_detail}" if ping_detail else ""
)
_add_startup_check(
"ENABLED" if (ok_val and ping_ok) else "DISABLED",
"0x0",
provider="0x0",
detail=detail,
)
if _has_provider(config, "matrix"):
try:
from Provider.matrix import Matrix
provider = Matrix(config)
matrix_conf = (
config.get("provider",
{}).get("matrix",
{}) if isinstance(config,
dict) else {}
)
homeserver = str(matrix_conf.get("homeserver") or "").strip()
room_id = str(matrix_conf.get("room_id") or "").strip()
if homeserver and not homeserver.startswith("http"):
homeserver = f"https://{homeserver}"
target = homeserver.rstrip("/")
if room_id:
target = (
target + (" " if target else "")
) + f"room:{room_id}"
_add_startup_check(
"ENABLED" if provider.validate() else "DISABLED",
"Matrix",
provider="matrix",
detail=target or
("Connected" if provider.validate() else "Not configured"),
)
except Exception as exc:
_add_startup_check(
"DISABLED",
"Matrix",
provider="matrix",
detail=str(exc)
) )
if _has_store_subtype(config, "debrid"): if _has_store_subtype(config, "debrid"):
@@ -2213,9 +2034,7 @@ Come to love it when others take what you share, as there is no greater joy
) )
try: try:
from tool.ytdlp import YtDlpTool cookiefile = resolve_cookies_path(config)
cookiefile = YtDlpTool(config).resolve_cookiefile()
if cookiefile is not None: if cookiefile is not None:
_add_startup_check("FOUND", "Cookies", detail=str(cookiefile)) _add_startup_check("FOUND", "Cookies", detail=str(cookiefile))
else: else:
+35 -25
View File
@@ -67,6 +67,7 @@ from SYS.config import load_config, reload_config # noqa: E402
from SYS.logger import set_debug, debug, set_thread_stream # noqa: E402 from SYS.logger import set_debug, debug, set_thread_stream # noqa: E402
from SYS.repl_queue import enqueue_repl_command # noqa: E402 from SYS.repl_queue import enqueue_repl_command # noqa: E402
from SYS.utils import format_bytes # noqa: E402 from SYS.utils import format_bytes # noqa: E402
from ProviderCore.registry import get_plugin, get_plugin_class # noqa: E402
REQUEST_PROP = "user-data/medeia-pipeline-request" REQUEST_PROP = "user-data/medeia-pipeline-request"
RESPONSE_PROP = "user-data/medeia-pipeline-response" RESPONSE_PROP = "user-data/medeia-pipeline-response"
@@ -936,39 +937,36 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
"table": None, "table": None,
} }
cfg = load_config() or {}
plugin = get_plugin("ytdlp", cfg)
if plugin is None or not hasattr(plugin, "list_url_formats"):
return {
"success": False,
"stdout": "",
"stderr": "",
"error": "yt-dlp plugin unavailable",
"table": None,
}
try: try:
from tool.ytdlp import list_formats, is_browseable_format # noqa: WPS433 formats = plugin.list_url_formats(
url,
no_playlist=True,
timeout_seconds=25,
)
except Exception as exc: except Exception as exc:
return { return {
"success": False, "success": False,
"stdout": "", "stdout": "",
"stderr": "", "stderr": "",
"error": f"yt-dlp tool unavailable: {type(exc).__name__}: {exc}", "error": f"yt-dlp plugin probe failed: {type(exc).__name__}: {exc}",
"table": None, "table": None,
} }
cookiefile = None
try:
from tool.ytdlp import YtDlpTool # noqa: WPS433
cfg = load_config() or {}
cookie_path = YtDlpTool(cfg).resolve_cookiefile()
if cookie_path is not None:
cookiefile = str(cookie_path)
except Exception:
cookiefile = None
def _format_bytes(n: Any) -> str: def _format_bytes(n: Any) -> str:
"""Format bytes using centralized utility.""" """Format bytes using centralized utility."""
return format_bytes(n) return format_bytes(n)
formats = list_formats(
url,
no_playlist=True,
cookiefile=cookiefile,
timeout_seconds=25,
)
if formats is None: if formats is None:
return { return {
"success": False, "success": False,
@@ -990,9 +988,10 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
}, },
} }
browseable = [f for f in formats if is_browseable_format(f)] try:
if browseable: formats = plugin.filter_picker_formats(formats)
formats = browseable except Exception:
pass
# Debug: dump a short summary of the format list to the helper log. # Debug: dump a short summary of the format list to the helper log.
try: try:
@@ -2040,8 +2039,19 @@ def main(argv: Optional[list[str]] = None) -> int:
# Publish yt-dlp supported domains for Lua menu filtering # Publish yt-dlp supported domains for Lua menu filtering
try: try:
from tool.ytdlp import _build_supported_domains plugin_class = get_plugin_class("ytdlp")
domains = sorted(list(_build_supported_domains())) domains = []
if plugin_class is not None:
domains = sorted(
{
str(value).strip().lower()
for value in plugin_class.url_patterns()
if isinstance(value, str)
and str(value).strip()
and "://" not in str(value)
and not str(value).strip().endswith(":")
}
)
if domains: if domains:
# We join them into a space-separated string for Lua to parse easily # We join them into a space-separated string for Lua to parse easily
domains_str = " ".join(domains) domains_str = " ".join(domains)
+54 -2
View File
@@ -64,8 +64,7 @@ def _format_total_seconds(seconds: Any) -> str:
class HIFI(Provider): class HIFI(Provider):
PLUGIN_NAME = "hifi"
PROVIDER_NAME = "hifi"
TABLE_AUTO_STAGES = { TABLE_AUTO_STAGES = {
"hifi.track": ["download-file"], "hifi.track": ["download-file"],
@@ -2092,3 +2091,56 @@ class HIFI(Provider):
pass pass
return True return True
def expand_selection(
self,
selected_items: List[Any],
*,
ctx: Any,
stage_is_last: bool = True,
table_type: str = "",
**_kwargs: Any,
) -> Optional[List[Any]]:
_ = ctx
if stage_is_last:
return None
normalized_table = str(table_type or "").strip().lower()
if normalized_table != "hifi.album":
return None
try:
contexts = self._extract_album_selection_context(selected_items)
except Exception:
return None
if not contexts:
return None
track_items: List[Any] = []
seen_track_ids: set[int] = set()
for album_id, album_title, artist_name in contexts:
try:
track_results = self._tracks_for_album(
album_id=album_id,
album_title=album_title,
artist_name=artist_name,
limit=500,
)
except Exception:
track_results = []
for track in track_results or []:
try:
metadata = getattr(track, "full_metadata", None)
track_id = None
if isinstance(metadata, dict):
raw_id = metadata.get("trackId") or metadata.get("id")
track_id = int(raw_id) if raw_id is not None else None
if track_id is not None:
if track_id in seen_track_ids:
continue
seen_track_ids.add(track_id)
except Exception:
pass
track_items.append(track)
return track_items or None
+58 -2
View File
@@ -64,7 +64,7 @@ def _format_total_seconds(seconds: Any) -> str:
class Tidal(Provider): class Tidal(Provider):
PROVIDER_NAME = "tidal" PLUGIN_NAME = "tidal"
TABLE_AUTO_STAGES = { TABLE_AUTO_STAGES = {
"tidal.track": ["download-file"], "tidal.track": ["download-file"],
@@ -82,7 +82,7 @@ class Tidal(Provider):
"tidal.com", "tidal.com",
"listen.tidal.com", "listen.tidal.com",
) )
URL = URL_DOMAINS URL = URL_DOMAINS + ("tidal:",)
"""Provider that targets the Tidal search endpoint. """Provider that targets the Tidal search endpoint.
The CLI can supply a list of fail-over URLs via ``provider.tidal.api_urls`` or The CLI can supply a list of fail-over URLs via ``provider.tidal.api_urls`` or
@@ -210,6 +210,9 @@ class Tidal(Provider):
self.api_timeout = 10.0 self.api_timeout = 10.0
self.api_clients = [TidalApiClient(base_url=url, timeout=self.api_timeout) for url in self.api_urls] self.api_clients = [TidalApiClient(base_url=url, timeout=self.api_timeout) for url in self.api_urls]
def resolve_playback_path(self, item: Any, **_kwargs: Any) -> Optional[str]:
return resolve_tidal_manifest_path(item)
def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]: def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]:
"""Parse inline `key:value` query arguments. """Parse inline `key:value` query arguments.
@@ -2399,3 +2402,56 @@ class Tidal(Provider):
pass pass
return True return True
def expand_selection(
self,
selected_items: List[Any],
*,
ctx: Any,
stage_is_last: bool = True,
table_type: str = "",
**_kwargs: Any,
) -> Optional[List[Any]]:
_ = ctx
if stage_is_last:
return None
normalized_table = str(table_type or "").strip().lower()
if normalized_table != "tidal.album":
return None
try:
contexts = self._extract_album_selection_context(selected_items)
except Exception:
return None
if not contexts:
return None
track_items: List[Any] = []
seen_track_ids: set[int] = set()
for album_id, album_title, artist_name in contexts:
try:
track_results = self._tracks_for_album(
album_id=album_id,
album_title=album_title,
artist_name=artist_name,
limit=500,
)
except Exception:
track_results = []
for track in track_results or []:
try:
metadata = getattr(track, "full_metadata", None)
track_id = None
if isinstance(metadata, dict):
raw_id = metadata.get("trackId") or metadata.get("id")
track_id = int(raw_id) if raw_id is not None else None
if track_id is not None:
if track_id in seen_track_ids:
continue
seen_track_ids.add(track_id)
except Exception:
pass
track_items.append(track)
return track_items or None
+3 -3
View File
@@ -1,7 +1,7 @@
"""Provider plugin modules. """Built-in plugin modules.
Concrete provider implementations live in this package. Concrete built-in plugins live in this package.
The public entrypoint/registry is ProviderCore.registry. The public registry lives in ProviderCore.registry.
""" """
# Register providers with the strict ResultTable adapter system # Register providers with the strict ResultTable adapter system
+72 -19
View File
@@ -351,7 +351,7 @@ def _dispatch_alldebrid_magnet_search(
if callable(exec_fn): if callable(exec_fn):
exec_fn( exec_fn(
None, None,
["-provider", "alldebrid", f"ID={magnet_id}"], ["-plugin", "alldebrid", f"ID={magnet_id}"],
config, config,
) )
except Exception: except Exception:
@@ -493,7 +493,7 @@ def download_magnet(
def expand_folder_item( def expand_folder_item(
item: Any, item: Any,
get_search_provider: Optional[Callable[[str, Dict[str, Any]], Any]], get_search_plugin: Optional[Callable[[str, Dict[str, Any]], Any]],
config: Dict[str, Any], config: Dict[str, Any],
) -> Tuple[List[Any], Optional[str]]: ) -> Tuple[List[Any], Optional[str]]:
table = getattr(item, "table", None) if not isinstance(item, dict) else item.get("table") table = getattr(item, "table", None) if not isinstance(item, dict) else item.get("table")
@@ -517,15 +517,15 @@ def expand_folder_item(
except Exception: except Exception:
magnet_id = None magnet_id = None
if magnet_id is None or get_search_provider is None: if magnet_id is None or get_search_plugin is None:
return [], None return [], None
provider = get_search_provider("alldebrid", config) if get_search_provider else None plugin = get_search_plugin("alldebrid", config) if get_search_plugin else None
if provider is None: if plugin is None:
return [], None return [], None
try: try:
files = provider.search("*", limit=10_000, filters={"view": "files", "magnet_id": int(magnet_id)}) files = plugin.search("*", limit=10_000, filters={"view": "files", "magnet_id": int(magnet_id)})
except Exception: except Exception:
files = [] files = []
@@ -609,7 +609,7 @@ class AllDebrid(TableProviderMixin, Provider):
- Drill-down: Selecting a folder row (@N) fetches and displays all files - Drill-down: Selecting a folder row (@N) fetches and displays all files
SELECTION FLOW: SELECTION FLOW:
1. User runs: search-file -provider alldebrid "ubuntu" 1. User runs: search-file -plugin alldebrid "ubuntu"
2. Results show magnet folders and (optionally) files 2. Results show magnet folders and (optionally) files
3. User selects a row: @1 3. User selects a row: @1
4. Selection metadata routes to download-file with -url alldebrid:magnet:<id> 4. Selection metadata routes to download-file with -url alldebrid:magnet:<id>
@@ -619,7 +619,7 @@ class AllDebrid(TableProviderMixin, Provider):
# Magnet URIs should be routed through this provider. # Magnet URIs should be routed through this provider.
TABLE_AUTO_STAGES = {"alldebrid": ["download-file"]} TABLE_AUTO_STAGES = {"alldebrid": ["download-file"]}
AUTO_STAGE_USE_SELECTION_ARGS = True AUTO_STAGE_USE_SELECTION_ARGS = True
URL = ("magnet:", "alldebrid:magnet:", "alldebrid:", "alldebrid🧲") URL = ("magnet:", "alldebrid:magnet:", "alldebrid:", "alldebrid🧲", "alldebrid.com")
URL_DOMAINS = () URL_DOMAINS = ()
def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]: def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]:
@@ -949,12 +949,10 @@ class AllDebrid(TableProviderMixin, Provider):
except Exception: except Exception:
return None return None
@classmethod def resolve_pipe_result_download(
def download_for_pipe_result( self,
cls,
result: Any, result: Any,
pipe_obj: Optional[PipeObject], pipe_obj: Optional[PipeObject],
config: Dict[str, Any],
) -> Tuple[Optional[Path], Optional[str], Optional[Path]]: ) -> Tuple[Optional[Path], Optional[str], Optional[Path]]:
"""Download a remote provider result on behalf of add-file.""" """Download a remote provider result on behalf of add-file."""
@@ -1026,8 +1024,7 @@ class AllDebrid(TableProviderMixin, Provider):
download_dir = Path(tempfile.mkdtemp(prefix="add-file-alldebrid-")) download_dir = Path(tempfile.mkdtemp(prefix="add-file-alldebrid-"))
try: try:
provider = cls(config) downloaded_path = self.download(search_result, download_dir)
downloaded_path = provider.download(search_result, download_dir)
if not downloaded_path: if not downloaded_path:
shutil.rmtree(download_dir, ignore_errors=True) shutil.rmtree(download_dir, ignore_errors=True)
return None, None, None return None, None, None
@@ -1049,6 +1046,62 @@ class AllDebrid(TableProviderMixin, Provider):
log(f"[alldebrid] add-file download failed: {exc}", file=sys.stderr) log(f"[alldebrid] add-file download failed: {exc}", file=sys.stderr)
shutil.rmtree(download_dir, ignore_errors=True) shutil.rmtree(download_dir, ignore_errors=True)
return None, None, None return None, None, None
def status_summary(self) -> Dict[str, Any]:
try:
api_key = _get_debrid_api_key(self.config)
if not api_key:
return {
"status": "DISABLED",
"name": self.label,
"plugin": self.name,
"detail": "Not configured",
}
client = AllDebridClient(api_key)
base_url = str(getattr(client, "base_url", "") or "").strip()
return {
"status": "ENABLED",
"name": self.label,
"plugin": self.name,
"detail": base_url or "Connected",
}
except Exception as exc:
return {
"status": "DISABLED",
"name": self.label,
"plugin": self.name,
"detail": str(exc),
}
def resolve_url(self, url: str, **_kwargs: Any) -> str:
target = str(url or "").strip()
if not target.startswith(("http://", "https://")):
return target
try:
parsed = urlparse(target)
host = str(parsed.netloc or "").lower()
path = str(parsed.path or "")
except Exception:
return target
if host != "alldebrid.com" or not path.startswith("/f/"):
return target
api_key = _get_debrid_api_key(self.config)
if not api_key:
return target
try:
client = AllDebridClient(str(api_key))
unlocked = client.unlock_link(target)
if isinstance(unlocked, str) and unlocked.strip():
return unlocked.strip()
except Exception:
pass
return target
def download_items( def download_items(
self, self,
result: SearchResult, result: SearchResult,
@@ -1413,7 +1466,7 @@ class AllDebrid(TableProviderMixin, Provider):
"provider_view": "files", "provider_view": "files",
# Selection metadata for table system # Selection metadata for table system
"_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"], "_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"],
"_selection_action": ["download-file", "-provider", "alldebrid", "-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"], "_selection_action": ["download-file", "-plugin", "alldebrid", "-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"],
} }
results.append( results.append(
@@ -1528,7 +1581,7 @@ class AllDebrid(TableProviderMixin, Provider):
"magnet_name": magnet_name, "magnet_name": magnet_name,
# Selection metadata: allow @N expansion to drive downloads directly # Selection metadata: allow @N expansion to drive downloads directly
"_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"], "_selection_args": ["-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"],
"_selection_action": ["download-file", "-provider", "alldebrid", "-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"], "_selection_action": ["download-file", "-plugin", "alldebrid", "-url", f"{_ALD_MAGNET_PREFIX}{magnet_id}"],
}, },
) )
) )
@@ -1629,7 +1682,7 @@ class AllDebrid(TableProviderMixin, Provider):
table.set_table_metadata({"provider": "alldebrid", "view": "files", "magnet_id": magnet_id}) table.set_table_metadata({"provider": "alldebrid", "view": "files", "magnet_id": magnet_id})
except Exception: except Exception:
pass pass
table.set_source_command("download-file", ["-provider", "alldebrid"]) table.set_source_command("download-file", ["-plugin", "alldebrid"])
results_payload: List[Dict[str, Any]] = [] results_payload: List[Dict[str, Any]] = []
for r in files or []: for r in files or []:
@@ -1662,7 +1715,7 @@ class AllDebrid(TableProviderMixin, Provider):
try: try:
from SYS.result_table_adapters import register_provider from SYS.result_table_adapters import register_plugin
from SYS.result_table_api import ColumnSpec, ResultModel, metadata_column, title_column from SYS.result_table_api import ColumnSpec, ResultModel, metadata_column, title_column
def _as_payload(item: Any) -> Dict[str, Any]: def _as_payload(item: Any) -> Dict[str, Any]:
@@ -1853,7 +1906,7 @@ try:
return ["-title", row.title or ""] return ["-title", row.title or ""]
register_provider( register_plugin(
"alldebrid", "alldebrid",
_adapter, _adapter,
columns=_columns_factory, columns=_columns_factory,
+9 -9
View File
@@ -1,4 +1,4 @@
"""Example provider that uses the new `ResultTable` API. """Example plugin that uses the new `ResultTable` API.
This module demonstrates a minimal provider adapter that yields `ResultModel` This module demonstrates a minimal provider adapter that yields `ResultModel`
instances, a set of `ColumnSpec` definitions, and a tiny CLI-friendly renderer instances, a set of `ColumnSpec` definitions, and a tiny CLI-friendly renderer
@@ -8,7 +8,7 @@ Run this to see sample output:
python -m Provider.example_provider python -m Provider.example_provider
Example usage (piped selector): Example usage (piped selector):
provider-table -provider example -sample | select -select 1 | add-file -store default plugin-table -plugin example -sample | select -select 1 | add-file -store default
""" """
from __future__ import annotations from __future__ import annotations
@@ -105,9 +105,9 @@ def selection_fn(row: ResultModel) -> List[str]:
return ["-title", row.title] return ["-title", row.title]
# Register the provider with the registry so callers can discover it by name # Register the plugin with the registry so callers can discover it by name
from SYS.result_table_adapters import register_provider from SYS.result_table_adapters import register_plugin
register_provider( register_plugin(
"example", "example",
adapter, adapter,
columns=columns_factory, columns=columns_factory,
@@ -223,17 +223,17 @@ def demo() -> None:
def demo_with_selection(idx: int = 0) -> None: def demo_with_selection(idx: int = 0) -> None:
"""Demonstrate how a cmdlet would use provider registration and selection args. """Demonstrate how a cmdlet would use plugin registration and selection args.
- Fetch the registered provider by name - Fetch the registered plugin by name
- Build rows via adapter - Build rows via adapter
- Render the table - Render the table
- Show the selection args for the chosen row; these are the args a cmdlet - Show the selection args for the chosen row; these are the args a cmdlet
would append when the user picks that row. would append when the user picks that row.
""" """
from SYS.result_table_adapters import get_provider from SYS.result_table_adapters import get_plugin
provider = get_provider("example") provider = get_plugin("example")
rows = list(provider.adapter(SAMPLE_ITEMS)) rows = list(provider.adapter(SAMPLE_ITEMS))
cols = provider.get_columns(rows) cols = provider.get_columns(rows)
+1 -1
View File
@@ -50,7 +50,7 @@ def _extract_key(payload: Any) -> Optional[str]:
class FileIO(Provider): class FileIO(Provider):
"""File provider for file.io.""" """File provider for file.io."""
PROVIDER_NAME = "file.io" PLUGIN_NAME = "file.io"
@classmethod @classmethod
def config_schema(cls) -> List[Dict[str, Any]]: def config_schema(cls) -> List[Dict[str, Any]]:
+5 -4
View File
@@ -1,6 +1,6 @@
"""Example provider template for use as a starter kit. """Example plugin template for use as a starter kit.
This minimal provider demonstrates the typical hooks a provider may implement: This minimal plugin demonstrates the typical hooks a plugin may implement:
- `validate()` to assert it's usable - `validate()` to assert it's usable
- `search()` to return `SearchResult` items - `search()` to return `SearchResult` items
- `download()` to persist a sample file (useful for local tests) - `download()` to persist a sample file (useful for local tests)
@@ -17,13 +17,14 @@ from ProviderCore.base import Provider, SearchResult
class HelloProvider(Provider): class HelloProvider(Provider):
"""Very small example provider suitable as a template. """Very small example plugin suitable as a template.
- Table name: `hello` - Table name: `hello`
- Usage: `search-file -provider hello "query"` - Usage: `search-file -plugin hello "query"`
- Selecting a row and piping into `download-file` will call `download()`. - Selecting a row and piping into `download-file` will call `download()`.
""" """
PLUGIN_NAME = "hello"
URL = ("hello:",) URL = ("hello:",)
URL_DOMAINS = () URL_DOMAINS = ()
+2 -2
View File
@@ -594,9 +594,9 @@ class InternetArchive(Provider):
"""Internet Archive provider using the `internetarchive` Python module. """Internet Archive provider using the `internetarchive` Python module.
Supports: Supports:
- search-file -provider internetarchive <query> - search-file -plugin internetarchive <query>
- download-file / provider.download() from search results - download-file / provider.download() from search results
- add-file -provider internetarchive (uploads) - add-file -plugin internetarchive (uploads)
""" """
URL = ("archive.org",) URL = ("archive.org",)
+18 -3
View File
@@ -294,7 +294,7 @@ class Matrix(TableProviderMixin, Provider):
- MIME detection: Automatic content type classification for Matrix msgtype - MIME detection: Automatic content type classification for Matrix msgtype
SELECTION FLOW: SELECTION FLOW:
1. User runs: search-file -provider matrix "room" (or .matrix -list-rooms) 1. User runs: search-file -plugin matrix "room" (or .matrix -list-rooms)
2. Results show available joined rooms 2. Results show available joined rooms
3. User selects rooms: @1 @2 (or @1,2) 3. User selects rooms: @1 @2 (or @1,2)
4. Selection triggers upload of pending files to selected rooms 4. Selection triggers upload of pending files to selected rooms
@@ -368,6 +368,21 @@ class Matrix(TableProviderMixin, Provider):
and matrix_conf.get("access_token") and matrix_conf.get("access_token")
) )
def status_summary(self) -> Dict[str, Any]:
matrix_conf = self.config.get("provider", {}).get("matrix", {}) if isinstance(self.config, dict) else {}
homeserver = str(matrix_conf.get("homeserver") or "").strip()
room_id = str(matrix_conf.get("room_id") or "").strip()
detail = homeserver
if room_id:
detail = (detail + (" " if detail else "")) + f"room:{room_id}"
enabled = bool(self.validate())
return {
"status": "ENABLED" if enabled else "DISABLED",
"name": self.label,
"plugin": self.name,
"detail": detail or ("Connected" if enabled else "Not configured"),
}
def search( def search(
self, self,
query: str, query: str,
@@ -767,7 +782,7 @@ class Matrix(TableProviderMixin, Provider):
# Minimal provider registration for the new table system # Minimal provider registration for the new table system
try: try:
from SYS.result_table_adapters import register_provider from SYS.result_table_adapters import register_plugin
from SYS.result_table_api import ResultModel, ColumnSpec, metadata_column, title_column from SYS.result_table_api import ResultModel, ColumnSpec, metadata_column, title_column
def _convert_search_result_to_model(sr: Any) -> ResultModel: def _convert_search_result_to_model(sr: Any) -> ResultModel:
@@ -850,7 +865,7 @@ try:
return ["-title", row.title or ""] return ["-title", row.title or ""]
register_provider( register_plugin(
"matrix", "matrix",
_adapter, _adapter,
columns=_columns_factory, columns=_columns_factory,
+355 -2
View File
@@ -40,6 +40,42 @@ except ImportError: # pragma: no cover - optional
yt_dlp = None yt_dlp = None
def _dedup_text_values(values: List[str]) -> List[str]:
out: List[str] = []
seen: set[str] = set()
for value in values or []:
if value is None:
continue
text = str(value).strip()
if not text:
continue
key = text.lower()
if key in seen:
continue
seen.add(key)
out.append(text)
return out
def _filter_default_scraped_tags(tags: List[str]) -> List[str]:
blocked = {"title", "artist", "source"}
out: List[str] = []
seen: set[str] = set()
for tag in tags or []:
text = str(tag or "").strip()
if not text:
continue
namespace = text.split(":", 1)[0].strip().lower() if ":" in text else ""
if namespace in blocked:
continue
key = text.lower()
if key in seen:
continue
seen.add(key)
out.append(text)
return out
class MetadataProvider(ABC): class MetadataProvider(ABC):
"""Base class for metadata providers (music, movies, books, etc.).""" """Base class for metadata providers (music, movies, books, etc.)."""
@@ -122,6 +158,64 @@ class MetadataProvider(ABC):
return False return False
def default_subject_scrape_priority(self) -> int:
"""Priority used when `get-tag -scrape` is invoked without an explicit provider."""
return 0
def url_scrape_priority(self, url: str) -> int:
"""Priority for handling a raw URL passed to `get-tag -scrape <url>`."""
_ = url
return 0
def resolve_subject_query(
self,
result: Any,
get_field: Any,
*,
backend: Any = None,
file_hash: Optional[str] = None,
) -> Optional[str]:
"""Resolve a provider-specific query from the current subject/result."""
_ = backend
_ = file_hash
return self.extract_url_query(result, get_field)
def prefers_store_tag_overwrite(self) -> bool:
"""Whether direct subject scrapes should replace the store tag set."""
return False
def filter_tags_for_selection(self, tags: List[str]) -> List[str]:
"""Filter scraped tags before presenting a selectable metadata row."""
return _filter_default_scraped_tags(tags)
def filter_tags_for_store_apply(self, tags: List[str]) -> List[str]:
"""Filter scraped tags before applying them to an existing store-backed item."""
return self.filter_tags_for_selection(tags)
def scrape_url_payload(self, url: str) -> Optional[Dict[str, Any]]:
"""Return a URL scrape payload for `get-tag -scrape <url>` when supported."""
items = self.search(url, limit=1)
if not items:
return None
item = items[0] if isinstance(items[0], dict) else {}
try:
tags = [str(t) for t in self.to_tags(item) if t is not None]
except Exception:
tags = []
return {
"title": item.get("title"),
"tag": _dedup_text_values(tags),
"formats": [],
"playlist_items": [],
}
class ITunesProvider(MetadataProvider): class ITunesProvider(MetadataProvider):
"""Metadata provider using the iTunes Search API.""" """Metadata provider using the iTunes Search API."""
@@ -1015,6 +1109,226 @@ class YtdlpMetadataProvider(MetadataProvider):
def emits_direct_tags(self) -> bool: def emits_direct_tags(self) -> bool:
return True return True
def default_subject_scrape_priority(self) -> int:
return 100
def url_scrape_priority(self, url: str) -> int:
text = str(url or "").strip()
if not text.startswith(("http://", "https://")):
return 0
return 100
def prefers_store_tag_overwrite(self) -> bool:
return True
def filter_tags_for_store_apply(self, tags: List[str]) -> List[str]:
return _dedup_text_values(tags)
def _resolve_candidate_urls_for_subject(
self,
result: Any,
get_field: Any,
*,
backend: Any = None,
file_hash: Optional[str] = None,
) -> List[str]:
try:
from SYS.metadata import normalize_urls
except Exception:
normalize_urls = None # type: ignore[assignment]
urls: List[str] = []
if backend is not None and file_hash:
try:
backend_urls = backend.get_url(file_hash, config=self.config)
if backend_urls:
if normalize_urls:
urls.extend(normalize_urls(backend_urls))
else:
urls.extend(
[str(u).strip() for u in backend_urls if isinstance(u, str) and str(u).strip()]
)
except Exception:
pass
try:
meta = backend.get_metadata(file_hash, config=self.config)
if isinstance(meta, dict) and meta.get("url"):
raw = meta.get("url")
if normalize_urls:
urls.extend(normalize_urls(raw))
elif isinstance(raw, list):
urls.extend([str(u).strip() for u in raw if isinstance(u, str) and str(u).strip()])
elif isinstance(raw, str) and raw.strip():
urls.append(raw.strip())
except Exception:
pass
for key in ("url", "webpage_url", "source_url", "target"):
val = get_field(result, key, None)
if not val:
continue
if normalize_urls:
urls.extend(normalize_urls(val))
continue
if isinstance(val, str) and val.strip():
urls.append(val.strip())
elif isinstance(val, list):
urls.extend([str(u).strip() for u in val if isinstance(u, str) and str(u).strip()])
meta_field = get_field(result, "metadata", None)
if isinstance(meta_field, dict) and meta_field.get("url"):
raw = meta_field.get("url")
if normalize_urls:
urls.extend(normalize_urls(raw))
elif isinstance(raw, list):
urls.extend([str(u).strip() for u in raw if isinstance(u, str) and str(u).strip()])
elif isinstance(raw, str) and raw.strip():
urls.append(raw.strip())
return _dedup_text_values(urls)
def _pick_supported_subject_url(self, urls: List[str]) -> Optional[str]:
if not urls:
return None
def _is_hydrus_file_url(u: str) -> bool:
text = str(u or "").strip().lower()
return bool(text and "/get_files/file" in text and "hash=" in text)
candidates = []
for url in urls:
text = str(url or "").strip()
if not text.startswith(("http://", "https://")):
continue
if _is_hydrus_file_url(text):
continue
candidates.append(text)
if not candidates:
return None
try:
from tool.ytdlp import is_url_supported_by_ytdlp
for text in candidates:
try:
if is_url_supported_by_ytdlp(text):
return text
except Exception:
continue
except Exception:
pass
return candidates[0] if candidates else None
def resolve_subject_query(
self,
result: Any,
get_field: Any,
*,
backend: Any = None,
file_hash: Optional[str] = None,
) -> Optional[str]:
candidate_urls = self._resolve_candidate_urls_for_subject(
result,
get_field,
backend=backend,
file_hash=file_hash,
)
return self._pick_supported_subject_url(candidate_urls)
@staticmethod
def _extract_url_formats(formats: Any) -> List[tuple[str, str]]:
if not isinstance(formats, list):
return []
video_formats: Dict[str, Dict[str, Any]] = {}
audio_formats: Dict[str, Dict[str, Any]] = {}
for fmt in formats:
if not isinstance(fmt, dict):
continue
vcodec = fmt.get("vcodec", "none")
acodec = fmt.get("acodec", "none")
height = fmt.get("height")
ext = fmt.get("ext", "unknown")
format_id = fmt.get("format_id", "")
tbr = fmt.get("tbr", 0)
abr = fmt.get("abr", 0)
if vcodec and vcodec != "none" and height:
if int(height) < 480:
continue
res_key = f"{int(height)}p"
if res_key not in video_formats or tbr > video_formats[res_key].get("tbr", 0):
video_formats[res_key] = {
"label": f"{int(height)}p ({ext})",
"format_id": str(format_id),
"tbr": tbr,
}
elif acodec and acodec != "none" and (not vcodec or vcodec == "none"):
audio_key = f"audio_{abr}"
if audio_key not in audio_formats or abr > audio_formats[audio_key].get("abr", 0):
audio_formats[audio_key] = {
"label": f"audio ({ext})",
"format_id": str(format_id),
"abr": abr,
}
result: List[tuple[str, str]] = []
for res in sorted(video_formats.keys(), key=lambda value: int(value.replace("p", "")), reverse=True):
fmt = video_formats[res]
result.append((str(fmt.get("label") or res), str(fmt.get("format_id") or "")))
if audio_formats:
best_audio_key = max(audio_formats.keys(), key=lambda key: float(audio_formats[key].get("abr", 0) or 0))
fmt = audio_formats[best_audio_key]
result.append((str(fmt.get("label") or "audio"), str(fmt.get("format_id") or "")))
return [entry for entry in result if entry[1]]
@staticmethod
def _build_playlist_items(raw: Dict[str, Any]) -> List[Dict[str, Any]]:
entries = raw.get("entries")
if not isinstance(entries, list):
return []
playlist_items: List[Dict[str, Any]] = []
for idx, entry in enumerate(entries, 1):
if not isinstance(entry, dict):
continue
playlist_items.append(
{
"index": idx,
"id": entry.get("id", f"track_{idx}"),
"title": entry.get("title", entry.get("id", f"Track {idx}")),
"duration": entry.get("duration", 0),
"url": entry.get("url") or entry.get("webpage_url", ""),
}
)
return playlist_items
def scrape_url_payload(self, url: str) -> Optional[Dict[str, Any]]:
info = self._extract_info(url)
if not isinstance(info, dict):
return None
item = {
"title": info.get("title") or "",
"artist": str(info.get("artist") or info.get("uploader") or info.get("channel") or ""),
"album": str(info.get("album") or info.get("playlist_title") or ""),
"year": str((str(info.get("release_date") or "") or str(info.get("upload_date") or ""))[:4]),
"provider": self.name,
"url": str(url or "").strip(),
"raw": info,
}
tags = _dedup_text_values([str(tag) for tag in self.to_tags(item) if tag is not None])
return {
"title": item.get("title") or None,
"tag": tags,
"formats": self._extract_url_formats(info.get("formats", [])),
"playlist_items": self._build_playlist_items(info),
}
def _coerce_archive_field_list(value: Any) -> List[str]: def _coerce_archive_field_list(value: Any) -> List[str]:
"""Coerce an Archive.org metadata field to a list of strings.""" """Coerce an Archive.org metadata field to a list of strings."""
@@ -1420,7 +1734,7 @@ try:
from typing import Iterable from typing import Iterable
from SYS.result_table_api import ColumnSpec, ResultModel, metadata_column, title_column from SYS.result_table_api import ColumnSpec, ResultModel, metadata_column, title_column
from SYS.result_table_adapters import register_provider from SYS.result_table_adapters import register_plugin
def _ensure_search_result(item: Any) -> SearchResult: def _ensure_search_result(item: Any) -> SearchResult:
if isinstance(item, SearchResult): if isinstance(item, SearchResult):
@@ -1526,7 +1840,7 @@ try:
return ["-url", url] return ["-url", url]
return ["-title", row.title or ""] return ["-title", row.title or ""]
register_provider( register_plugin(
"openlibrary", "openlibrary",
_adapter, _adapter,
columns=_columns_factory, columns=_columns_factory,
@@ -1671,3 +1985,42 @@ def get_metadata_provider(name: str,
except Exception as exc: except Exception as exc:
log(f"Provider init failed for '{name}': {exc}", file=sys.stderr) log(f"Provider init failed for '{name}': {exc}", file=sys.stderr)
return None return None
def get_default_subject_scrape_provider(
config: Optional[Dict[str, Any]] = None,
) -> Optional[MetadataProvider]:
best_provider: Optional[MetadataProvider] = None
best_priority = 0
for cls in _METADATA_PROVIDERS.values():
try:
provider = cls(config)
priority = int(provider.default_subject_scrape_priority())
except Exception:
continue
if priority > best_priority:
best_priority = priority
best_provider = provider
return best_provider
def get_metadata_provider_for_url(
url: str,
config: Optional[Dict[str, Any]] = None,
) -> Optional[MetadataProvider]:
text = str(url or "").strip()
if not text:
return None
best_provider: Optional[MetadataProvider] = None
best_priority = 0
for cls in _METADATA_PROVIDERS.values():
try:
provider = cls(config)
priority = int(provider.url_scrape_priority(text))
except Exception:
continue
if priority > best_priority:
best_priority = priority
best_provider = provider
return best_provider
+2 -2
View File
@@ -216,7 +216,7 @@ def _suppress_aioslsk_noise() -> Any:
class Soulseek(Provider): class Soulseek(Provider):
TABLE_AUTO_STAGES = { TABLE_AUTO_STAGES = {
"soulseek": ["download-file", "-provider", "soulseek"], "soulseek": ["download-file", "-plugin", "soulseek"],
} }
"""Search provider for Soulseek P2P network.""" """Search provider for Soulseek P2P network."""
@@ -623,7 +623,7 @@ class Soulseek(Provider):
media_kind="audio", media_kind="audio",
size_bytes=item["size"], size_bytes=item["size"],
columns=columns, columns=columns,
selection_action=["download-file", "-provider", "soulseek"], selection_action=["download-file", "-plugin", "soulseek"],
full_metadata={ full_metadata={
"username": item["username"], "username": item["username"],
"filename": item["filename"], "filename": item["filename"],
+10 -10
View File
@@ -37,15 +37,15 @@ class Vimm(TableProviderMixin, Provider):
2) Each row carries explicit selection args: `['-url', '<full-url>']`. 2) Each row carries explicit selection args: `['-url', '<full-url>']`.
Using an explicit `-url` flag avoids ambiguity during argument Using an explicit `-url` flag avoids ambiguity during argument
parsing (some cmdlets accept positional URLs, others accept flags). parsing (some cmdlets accept positional URLs, others accept flags).
3) The CLI's expansion logic places selection args *before* provider 3) The CLI's expansion logic places selection args *before* plugin
source args (e.g., `-provider vimm`) so the first positional token is source args (e.g., `-plugin vimm`) so the first positional token is
the intended URL (not an unknown flag like `-provider`). the intended URL (not an unknown flag like `-plugin`).
- Why this approach? Argument parsing treats the *first* unrecognized token - Why this approach? Argument parsing treats the *first* unrecognized token
as a positional value (commonly interpreted as a URL). If a provider as a positional value (commonly interpreted as a URL). If a plugin
injects hints like `-provider vimm` *before* a bare URL, the parser can injects hints like `-plugin vimm` *before* a bare URL, the parser can
misinterpret `-provider` as the URL, causing confusing attempts to misinterpret `-plugin` as the URL, causing confusing attempts to
download `-provider`. By using `-url` and ensuring the URL appears first download `-plugin`. By using `-url` and ensuring the URL appears first
we avoid that class of bugs and make `@N` -> `download-file`/`add-file` we avoid that class of bugs and make `@N` -> `download-file`/`add-file`
flows reliable. flows reliable.
@@ -56,7 +56,7 @@ class Vimm(TableProviderMixin, Provider):
URL_DOMAINS = ("vimm.net",) URL_DOMAINS = ("vimm.net",)
def get_source_command(self, args_list: List[str]) -> Tuple[str, List[str]]: def get_source_command(self, args_list: List[str]) -> Tuple[str, List[str]]:
return "search-file", ["-provider", self.name] return "search-file", ["-plugin", self.name]
REGION_CHOICES = [ REGION_CHOICES = [
{"value": "1", "text": "Argentina"}, {"value": "1", "text": "Argentina"},
@@ -807,7 +807,7 @@ class Vimm(TableProviderMixin, Provider):
# Minimal provider registration # Minimal provider registration
try: try:
from SYS.result_table_adapters import register_provider from SYS.result_table_adapters import register_plugin
from SYS.result_table_api import ResultModel, title_column, metadata_column from SYS.result_table_api import ResultModel, title_column, metadata_column
def _convert_search_result_to_model(sr): def _convert_search_result_to_model(sr):
@@ -857,7 +857,7 @@ try:
return ["-title", row.title or ""] return ["-title", row.title or ""]
register_provider( register_plugin(
"vimm", "vimm",
_adapter, _adapter,
columns=_columns_factory, columns=_columns_factory,
+3 -3
View File
@@ -21,7 +21,7 @@ class YouTube(TableProviderMixin, Provider):
- _selection_args: For @N expansion control and download-file routing - _selection_args: For @N expansion control and download-file routing
SELECTION FLOW: SELECTION FLOW:
1. User runs: search-file -provider youtube "linux tutorial" 1. User runs: search-file -plugin youtube "linux tutorial"
2. Results show video rows with uploader, duration, views 2. Results show video rows with uploader, duration, views
3. User selects a video: @1 3. User selects a video: @1
4. Selection metadata routes to download-file with the YouTube URL 4. Selection metadata routes to download-file with the YouTube URL
@@ -121,7 +121,7 @@ class YouTube(TableProviderMixin, Provider):
# Minimal provider registration for the new table system # Minimal provider registration for the new table system
try: try:
from SYS.result_table_adapters import register_provider from SYS.result_table_adapters import register_plugin
from SYS.result_table_api import ResultModel, ColumnSpec, metadata_column, title_column from SYS.result_table_api import ResultModel, ColumnSpec, metadata_column, title_column
def _convert_search_result_to_model(sr: Any) -> ResultModel: def _convert_search_result_to_model(sr: Any) -> ResultModel:
@@ -206,7 +206,7 @@ try:
return ["-title", row.title or ""] return ["-title", row.title or ""]
register_provider( register_plugin(
"youtube", "youtube",
_adapter, _adapter,
columns=_columns_factory, columns=_columns_factory,
+1204 -150
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -11,8 +11,8 @@ from SYS.logger import log
class ZeroXZero(Provider): class ZeroXZero(Provider):
"""File provider for 0x0.st.""" """File provider for 0x0.st."""
NAME = "0x0" PLUGIN_NAME = "0x0"
PROVIDER_ALIASES = ("zeroxzero",) PLUGIN_ALIASES = ("zeroxzero",)
def upload(self, file_path: str, **kwargs: Any) -> str: def upload(self, file_path: str, **kwargs: Any) -> str:
from API.HTTP import HTTPClient from API.HTTP import HTTPClient
+4 -3
View File
@@ -1,5 +1,6 @@
"""Provider core modules. """Plugin core modules.
This package contains the provider framework (base types, registry, and shared helpers). This package contains the plugin framework (base types, registry, and shared
Concrete provider implementations live in the `Provider/` package. helpers). Built-in plugins continue to live in the `Provider/` package for
backward compatibility.
""" """
+139 -16
View File
@@ -10,9 +10,9 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple, Callable
@dataclass @dataclass
class SearchResult: class SearchResult:
"""Unified search result format across all search providers.""" """Unified search result format across all search plugins."""
table: str # Provider name: "libgen", "soulseek", "bandcamp", "youtube", etc. table: str # Plugin name: "libgen", "soulseek", "bandcamp", "youtube", etc.
title: str # Display title/filename title: str # Display title/filename
path: str # Download target (URL, path, magnet, identifier) path: str # Download target (URL, path, magnet, identifier)
@@ -84,7 +84,7 @@ class SearchResult:
def parse_inline_query_arguments(raw_query: str) -> Tuple[str, Dict[str, str]]: def parse_inline_query_arguments(raw_query: str) -> Tuple[str, Dict[str, str]]:
"""Extract inline key:value arguments from a provider search query.""" """Extract inline key:value arguments from a plugin search query."""
query_text = str(raw_query or "").strip() query_text = str(raw_query or "").strip()
if not query_text: if not query_text:
@@ -112,10 +112,10 @@ def parse_inline_query_arguments(raw_query: str) -> Tuple[str, Dict[str, str]]:
class Provider(ABC): class Provider(ABC):
"""Unified provider base class. """Unified plugin base class.
This replaces the older split between "search providers" and "file providers". This replaces the older split between search and upload providers.
Concrete providers may implement any subset of: Concrete plugins may implement any subset of:
- search(query, ...) - search(query, ...)
- download(result, output_dir) - download(result, output_dir)
- upload(file_path, ...) - upload(file_path, ...)
@@ -124,7 +124,8 @@ class Provider(ABC):
""" """
URL: Sequence[str] = () URL: Sequence[str] = ()
NAME: str = "" PLUGIN_NAME: str = ""
PLUGIN_ALIASES: Sequence[str] = ()
# Optional provider-driven defaults for what to do when a user selects @N from a # Optional provider-driven defaults for what to do when a user selects @N from a
# provider table. The CLI uses this to auto-insert stages (e.g. download-file) # provider table. The CLI uses this to auto-insert stages (e.g. download-file)
@@ -141,24 +142,23 @@ class Provider(ABC):
# Used for dynamically generating config panels (e.g., missing credentials). # Used for dynamically generating config panels (e.g., missing credentials).
REQUIRED_CONFIG_KEYS: Sequence[str] = () REQUIRED_CONFIG_KEYS: Sequence[str] = ()
# Some providers implement `upload()` but are not intended to be used as # Some plugins implement `upload()` but are not intended to be used as
# generic "file host" providers via `add-file -provider ...`. # generic "file host" plugins via `add-file -plugin ...`.
EXPOSE_AS_FILE_PROVIDER: bool = True EXPOSE_AS_FILE_PROVIDER: bool = True
def __init__(self, config: Optional[Dict[str, Any]] = None): def __init__(self, config: Optional[Dict[str, Any]] = None):
self.config = config or {} self.config = config or {}
# Prioritize explicit NAME property for the instance name
self.name = str( self.name = str(
getattr(self, "NAME", None) getattr(self, "PLUGIN_NAME", None)
or getattr(self, "PROVIDER_NAME", None)
or self.__class__.__name__ or self.__class__.__name__
).lower() ).lower()
@property @property
def label(self) -> str: def label(self) -> str:
"""Friendly display name for the provider.""" """Friendly display name for the plugin."""
if hasattr(self, "NAME") and self.NAME: name = str(getattr(self, "PLUGIN_NAME", None) or self.__class__.__name__)
name = str(self.NAME)
if name:
if name.lower() == "loc": if name.lower() == "loc":
return "LoC" return "LoC"
if name.lower() == "openlibrary": if name.lower() == "openlibrary":
@@ -186,7 +186,7 @@ class Provider(ABC):
def get_table_metadata(self, query: str, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: def get_table_metadata(self, query: str, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Return metadata for the results table.""" """Return metadata for the results table."""
return {"provider": self.name} return {"plugin": self.name}
def get_source_command(self, args_list: List[str]) -> Tuple[str, List[str]]: def get_source_command(self, args_list: List[str]) -> Tuple[str, List[str]]:
"""Return the command and arguments that produced this search result. """Return the command and arguments that produced this search result.
@@ -308,6 +308,49 @@ class Provider(ABC):
_ = config _ = config
return 0 return 0
def resolve_pipe_result_download(
self,
result: Any,
pipe_obj: Any,
) -> Tuple[Optional[Path], Optional[str], Optional[Path]]:
"""Materialize a piped plugin result into a local file for add-file."""
_ = result
_ = pipe_obj
return None, None, None
def expand_selection(
self,
selected_items: List[Any],
*,
ctx: Any,
stage_is_last: bool = True,
table_type: str = "",
**_kwargs: Any,
) -> Optional[List[Any]]:
"""Optionally expand a selection into downstream items for non-terminal pipelines."""
_ = selected_items
_ = ctx
_ = stage_is_last
_ = table_type
return None
def status_summary(self) -> Dict[str, Any]:
"""Return plugin-owned status details for startup/status views."""
enabled = False
try:
enabled = bool(self.validate())
except Exception:
enabled = False
return {
"status": "ENABLED" if enabled else "DISABLED",
"name": self.label,
"plugin": self.name,
"detail": "Configured" if enabled else "Not configured",
}
def handle_url(self, url: str, *, output_dir: Optional[Path] = None) -> Tuple[bool, Optional[Path | Dict[str, Any]]]: def handle_url(self, url: str, *, output_dir: Optional[Path] = None) -> Tuple[bool, Optional[Path | Dict[str, Any]]]:
"""Optional provider override to parse and act on URLs.""" """Optional provider override to parse and act on URLs."""
@@ -315,6 +358,67 @@ class Provider(ABC):
_ = output_dir _ = output_dir
return False, None return False, None
def download_url(self, url: str, output_dir: Path, **_kwargs: Any) -> Optional[Any]:
"""Optional direct-URL download hook used by generic cmdlets."""
_ = url
_ = output_dir
return None
def resolve_url(self, url: str, **_kwargs: Any) -> str:
"""Optionally normalize or exchange a URL before downstream use."""
return str(url or "")
def resolve_playback_path(self, item: Any, **_kwargs: Any) -> Optional[str]:
"""Optionally turn a plugin-owned item into a playable local path or URL."""
_ = item
return None
def list_url_formats(self, url: str, **_kwargs: Any) -> Optional[List[Dict[str, Any]]]:
"""Optionally return picker-friendly format metadata for a URL."""
_ = url
return None
def filter_picker_formats(
self,
formats: List[Dict[str, Any]],
**_kwargs: Any,
) -> List[Dict[str, Any]]:
"""Optionally filter or reorder raw format rows before UI display."""
return list(formats or [])
def enrich_playlist_entries(
self,
entries: List[Dict[str, Any]],
**_kwargs: Any,
) -> Optional[List[Dict[str, Any]]]:
"""Optionally expand lightweight playlist entries with richer metadata."""
_ = entries
return None
def maybe_show_picker(
self,
*,
url: str,
item: Optional[Any] = None,
parsed: Optional[Dict[str, Any]] = None,
config: Optional[Dict[str, Any]] = None,
quiet_mode: bool = False,
) -> Optional[int]:
"""Optional hook for plugins that want to render an interactive picker/table."""
_ = url
_ = item
_ = parsed
_ = config
_ = quiet_mode
return None
def upload(self, file_path: str, **kwargs: Any) -> str: def upload(self, file_path: str, **kwargs: Any) -> str:
"""Upload a file and return a URL or identifier.""" """Upload a file and return a URL or identifier."""
raise NotImplementedError(f"Provider '{self.name}' does not support upload") raise NotImplementedError(f"Provider '{self.name}' does not support upload")
@@ -419,6 +523,25 @@ class Provider(ABC):
patterns.append(candidate) patterns.append(candidate)
return tuple(patterns) return tuple(patterns)
@classmethod
def selection_url_prefixes(cls) -> Tuple[str, ...]:
"""Return URL-like prefixes that selection parsing should treat as URLs."""
prefixes: List[str] = []
seen: set[str] = set()
for pattern in cls.url_patterns():
try:
candidate = str(pattern or "").strip().lower()
except Exception:
continue
if not candidate:
continue
if "://" in candidate or candidate.endswith(":") or "🧲" in candidate:
if candidate not in seen:
seen.add(candidate)
prefixes.append(candidate)
return tuple(prefixes)
class SearchProvider(Provider): class SearchProvider(Provider):
"""Compatibility alias for older code. """Compatibility alias for older code.
+225 -92
View File
@@ -1,17 +1,21 @@
"""Provider registry. """Plugin registry.
Concrete provider implementations live in the ``Provider`` package. This module Built-in plugin implementations live in the ``Provider`` package. External user
is the single source of truth for discovery, metadata, and lifecycle helpers plugins can be dropped into a repo-local ``plugins/`` directory or discovered
for those plugins. via environment-configured plugin paths.
""" """
from __future__ import annotations from __future__ import annotations
from functools import lru_cache from functools import lru_cache
import hashlib
import importlib import importlib
import importlib.util
import os
import pkgutil import pkgutil
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from types import ModuleType from types import ModuleType
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Type from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Type
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -21,21 +25,85 @@ from SYS.logger import log, debug
from ProviderCore.base import FileProvider, Provider, SearchProvider, SearchResult from ProviderCore.base import FileProvider, Provider, SearchProvider, SearchResult
def download_soulseek_file(*args: Any, **kwargs: Any) -> Any: _EXTERNAL_PLUGIN_ENV_VARS: tuple[str, ...] = ("MM_PLUGIN_PATH", "MEDEIA_PLUGIN_PATH")
"""Lazy proxy for the soulseek downloader.
Importing the provider modules can be expensive; keeping this lazy avoids
paying that cost at registry import time.
"""
from Provider.soulseek import download_soulseek_file as _download def _repo_root() -> Path:
try:
return Path(__file__).resolve().parents[1]
except Exception:
return Path.cwd()
return _download(*args, **kwargs)
def _iter_external_plugin_dirs() -> Tuple[Path, ...]:
seen: set[str] = set()
dirs: List[Path] = []
candidates: List[Path] = [_repo_root() / "plugins"]
try:
cwd_plugins = Path.cwd() / "plugins"
if cwd_plugins not in candidates:
candidates.append(cwd_plugins)
except Exception:
pass
for env_name in _EXTERNAL_PLUGIN_ENV_VARS:
raw_value = str(os.environ.get(env_name, "") or "").strip()
if not raw_value:
continue
for chunk in raw_value.split(os.pathsep):
text = str(chunk or "").strip().strip('"')
if not text:
continue
candidates.append(Path(text).expanduser())
for candidate in candidates:
try:
resolved = candidate.resolve()
except Exception:
resolved = candidate
key = str(resolved).lower()
if key in seen:
continue
seen.add(key)
try:
if resolved.exists() and resolved.is_dir():
dirs.append(resolved)
except Exception:
continue
return tuple(dirs)
def _iter_external_plugin_entries(plugin_dir: Path) -> Iterable[Tuple[str, Path, bool]]:
try:
children = sorted(plugin_dir.iterdir(), key=lambda entry: entry.name.lower())
except Exception:
return ()
out: List[Tuple[str, Path, bool]] = []
for child in children:
name = str(child.name or "").strip()
if not name or name.startswith("."):
continue
if child.is_file() and child.suffix.lower() == ".py" and child.stem != "__init__":
fingerprint = hashlib.sha1(str(child).encode("utf-8", errors="ignore")).hexdigest()[:10]
out.append((f"_medeia_plugin_{child.stem}_{fingerprint}", child, False))
continue
if child.is_dir():
init_py = child / "__init__.py"
if not init_py.exists() or not init_py.is_file():
continue
fingerprint = hashlib.sha1(str(child).encode("utf-8", errors="ignore")).hexdigest()[:10]
out.append((f"_medeia_plugin_pkg_{child.name}_{fingerprint}", init_py, True))
return tuple(out)
@dataclass(frozen=True) @dataclass(frozen=True)
class ProviderInfo: class ProviderInfo:
"""Metadata about a single provider entry.""" """Metadata about a single plugin entry."""
canonical_name: str canonical_name: str
provider_class: Type[Provider] provider_class: Type[Provider]
@@ -56,14 +124,16 @@ class ProviderInfo:
class ProviderRegistry: class ProviderRegistry:
"""Handles discovery, registration, and lookup of provider classes.""" """Handles discovery, registration, and lookup of built-in and external plugins."""
def __init__(self, package_name: str) -> None: def __init__(self, package_name: str) -> None:
self.package_name = (package_name or "").strip() self.package_name = (package_name or "").strip()
self._infos: Dict[str, ProviderInfo] = {} self._infos: Dict[str, ProviderInfo] = {}
self._lookup: Dict[str, ProviderInfo] = {} self._lookup: Dict[str, ProviderInfo] = {}
self._modules: set[str] = set() self._modules: set[str] = set()
self._external_modules: set[str] = set()
self._discovered = False self._discovered = False
self._external_dirs_scanned = False
def _normalize(self, value: Any) -> str: def _normalize(self, value: Any) -> str:
return str(value or "").strip().lower() return str(value or "").strip().lower()
@@ -85,12 +155,10 @@ class ProviderRegistry:
if override_name: if override_name:
_add(override_name) _add(override_name)
else: else:
# Use explicit NAME or PROVIDER_NAME if available, else class name _add(getattr(provider_class, "PLUGIN_NAME", None))
_add(getattr(provider_class, "NAME", None))
_add(getattr(provider_class, "PROVIDER_NAME", None))
_add(getattr(provider_class, "__name__", None)) _add(getattr(provider_class, "__name__", None))
for alias in getattr(provider_class, "PROVIDER_ALIASES", ()) or (): for alias in getattr(provider_class, "PLUGIN_ALIASES", ()) or ():
_add(alias) _add(alias)
return names return names
@@ -104,14 +172,14 @@ class ProviderRegistry:
module_name: Optional[str] = None, module_name: Optional[str] = None,
replace: bool = False, replace: bool = False,
) -> ProviderInfo: ) -> ProviderInfo:
"""Register a provider class with canonical and alias names.""" """Register a plugin class with canonical and alias names."""
candidates = self._candidate_names(provider_class, override_name) candidates = self._candidate_names(provider_class, override_name)
if not candidates: if not candidates:
raise ValueError("provider name candidates are required") raise ValueError("plugin name candidates are required")
canonical = self._normalize(candidates[0]) canonical = self._normalize(candidates[0])
if not canonical: if not canonical:
raise ValueError("provider name must not be empty") raise ValueError("plugin name must not be empty")
alias_names: List[str] = [] alias_names: List[str] = []
alias_seen: set[str] = set() alias_seen: set[str] = set()
@@ -165,7 +233,44 @@ class ProviderRegistry:
try: try:
self.register(candidate, module_name=module_name) self.register(candidate, module_name=module_name)
except Exception as exc: except Exception as exc:
log(f"[provider] Failed to register {module_name}.{candidate.__name__}: {exc}", file=sys.stderr) log(f"[plugin] Failed to register {module_name}.{candidate.__name__}: {exc}", file=sys.stderr)
def _discover_external_plugins(self) -> None:
if self._external_dirs_scanned:
return
self._external_dirs_scanned = True
for plugin_dir in _iter_external_plugin_dirs():
try:
plugin_dir_str = str(plugin_dir)
if plugin_dir_str and plugin_dir_str not in sys.path:
sys.path.insert(0, plugin_dir_str)
except Exception:
pass
for module_name, module_path, is_package in _iter_external_plugin_entries(plugin_dir):
if module_name in self._external_modules:
continue
try:
if is_package:
spec = importlib.util.spec_from_file_location(
module_name,
str(module_path),
submodule_search_locations=[str(module_path.parent)],
)
else:
spec = importlib.util.spec_from_file_location(module_name, str(module_path))
if spec is None or spec.loader is None:
raise ImportError("missing module spec loader")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
self._external_modules.add(module_name)
self._register_module(module)
except Exception as exc:
log(f"[plugin] Failed to load external plugin {module_path}: {exc}", file=sys.stderr)
def discover(self) -> None: def discover(self) -> None:
"""Import and register providers from the package.""" """Import and register providers from the package."""
@@ -177,12 +282,13 @@ class ProviderRegistry:
try: try:
package = importlib.import_module(self.package_name) package = importlib.import_module(self.package_name)
except Exception as exc: except Exception as exc:
log(f"[provider] Failed to import package {self.package_name}: {exc}", file=sys.stderr) log(f"[plugin] Failed to import package {self.package_name}: {exc}", file=sys.stderr)
return return
self._register_module(package) self._register_module(package)
package_path = getattr(package, "__path__", None) package_path = getattr(package, "__path__", None)
if not package_path: if not package_path:
self._discover_external_plugins()
return return
for finder, module_name, _ in pkgutil.iter_modules(package_path): for finder, module_name, _ in pkgutil.iter_modules(package_path):
@@ -194,18 +300,19 @@ class ProviderRegistry:
try: try:
module = importlib.import_module(module_path) module = importlib.import_module(module_path)
except Exception as exc: except Exception as exc:
log(f"[provider] Failed to load {module_path}: {exc}", file=sys.stderr) log(f"[plugin] Failed to load {module_path}: {exc}", file=sys.stderr)
continue continue
self._register_module(module) self._register_module(module)
# Pick up any Provider subclasses loaded via other mechanisms. # Pick up any Provider subclasses loaded via other mechanisms.
self._sync_subclasses() self._sync_subclasses()
self._discover_external_plugins()
def _try_import_for_name(self, normalized_name: str) -> None: def _try_import_for_name(self, normalized_name: str) -> None:
"""Best-effort import for a single provider module. """Best-effort import for a single plugin module.
This avoids importing every provider module when the caller only needs This avoids importing every provider module when the caller only needs
one provider (common for CLI usage). one plugin (common for CLI usage).
""" """
name = str(normalized_name or "").strip().lower() name = str(normalized_name or "").strip().lower()
if not name or not self.package_name: if not name or not self.package_name:
@@ -249,6 +356,7 @@ class ProviderRegistry:
# module that matches the requested name. # module that matches the requested name.
if not self._discovered: if not self._discovered:
self._try_import_for_name(normalized) self._try_import_for_name(normalized)
self._discover_external_plugins()
info = self._lookup.get(normalized) info = self._lookup.get(normalized)
if info is not None: if info is not None:
return info return info
@@ -279,6 +387,9 @@ class ProviderRegistry:
_walk(Provider) _walk(Provider)
REGISTRY = ProviderRegistry("Provider") REGISTRY = ProviderRegistry("Provider")
PLUGIN_REGISTRY = REGISTRY
PluginInfo = ProviderInfo
PluginRegistry = ProviderRegistry
@lru_cache(maxsize=512) @lru_cache(maxsize=512)
@@ -289,18 +400,16 @@ def _provider_url_patterns(provider_class: Type[Provider]) -> Sequence[str]:
return [] return []
def register_provider( def register_plugin(
provider_class: Type[Provider], plugin_class: Type[Provider],
*, *,
name: Optional[str] = None, name: Optional[str] = None,
aliases: Optional[Sequence[str]] = None, aliases: Optional[Sequence[str]] = None,
module_name: Optional[str] = None, module_name: Optional[str] = None,
replace: bool = False, replace: bool = False,
) -> ProviderInfo: ) -> ProviderInfo:
"""Register a provider class from tests or third-party packages."""
return REGISTRY.register( return REGISTRY.register(
provider_class, plugin_class,
override_name=name, override_name=name,
extra_aliases=aliases, extra_aliases=aliases,
module_name=module_name, module_name=module_name,
@@ -308,7 +417,7 @@ def register_provider(
) )
def get_provider_class(name: str) -> Optional[Type[Provider]]: def get_plugin_class(name: str) -> Optional[Type[Provider]]:
info = REGISTRY.get(name) info = REGISTRY.get(name)
if info is None: if info is None:
return None return None
@@ -323,18 +432,18 @@ def selection_auto_stage_for_table(
if not t: if not t:
return None return None
provider_key = t.split(".", 1)[0] if "." in t else t plugin_key = t.split(".", 1)[0] if "." in t else t
provider_class = get_provider_class(provider_key) or get_provider_class(t) plugin_class = get_plugin_class(plugin_key) or get_plugin_class(t)
if provider_class is None: if plugin_class is None:
return None return None
try: try:
return provider_class.selection_auto_stage(t, stage_args) return plugin_class.selection_auto_stage(t, stage_args)
except Exception: except Exception:
return None return None
def is_known_provider_name(name: str) -> bool: def is_known_plugin_name(name: str) -> bool:
return REGISTRY.has_name(name) return REGISTRY.has_name(name)
@@ -406,83 +515,83 @@ def _collect_inline_choice_mapping(provider: Provider) -> Dict[str, List[Dict[st
return mapping return mapping
def get_provider(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[Provider]: def get_plugin(name: str, config: Optional[Dict[str, Any]] = None) -> Optional[Provider]:
info = REGISTRY.get(name) info = REGISTRY.get(name)
if info is None: if info is None:
debug(f"[provider] Unknown provider: {name}") debug(f"[plugin] Unknown plugin: {name}")
return None return None
try: try:
provider = info.provider_class(config) plugin = info.provider_class(config)
if not provider.validate(): if not plugin.validate():
debug(f"[provider] Provider '{name}' is not available") debug(f"[plugin] Plugin '{name}' is not available")
return None return None
return provider return plugin
except Exception as exc: except Exception as exc:
debug(f"[provider] Error initializing '{name}': {exc}") debug(f"[plugin] Error initializing '{name}': {exc}")
return None return None
def list_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]: def list_plugins(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
availability: Dict[str, bool] = {} availability: Dict[str, bool] = {}
for info in REGISTRY.iter_providers(): for info in REGISTRY.iter_providers():
try: try:
provider = info.provider_class(config) plugin = info.provider_class(config)
availability[info.canonical_name] = provider.validate() availability[info.canonical_name] = plugin.validate()
except Exception: except Exception:
availability[info.canonical_name] = False availability[info.canonical_name] = False
return availability return availability
def get_search_provider(name: str, def get_search_plugin(name: str,
config: Optional[Dict[str, Any]] = None) -> Optional[SearchProvider]: config: Optional[Dict[str, Any]] = None) -> Optional[SearchProvider]:
provider = get_provider(name, config) plugin = get_plugin(name, config)
if provider is None: if plugin is None:
return None return None
if not _supports_search(provider): if not _supports_search(plugin):
debug(f"[provider] Provider '{name}' does not support search") debug(f"[plugin] Plugin '{name}' does not support search")
return None return None
return provider # type: ignore[return-value] return plugin # type: ignore[return-value]
def list_search_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]: def list_search_plugins(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
availability: Dict[str, bool] = {} availability: Dict[str, bool] = {}
for info in REGISTRY.iter_providers(): for info in REGISTRY.iter_providers():
try: try:
provider = info.provider_class(config) plugin = info.provider_class(config)
availability[info.canonical_name] = bool( availability[info.canonical_name] = bool(
provider.validate() and info.supports_search plugin.validate() and info.supports_search
) )
except Exception: except Exception:
availability[info.canonical_name] = False availability[info.canonical_name] = False
return availability return availability
def get_file_provider(name: str, def get_upload_plugin(name: str,
config: Optional[Dict[str, Any]] = None) -> Optional[FileProvider]: config: Optional[Dict[str, Any]] = None) -> Optional[FileProvider]:
provider = get_provider(name, config) plugin = get_plugin(name, config)
if provider is None: if plugin is None:
return None return None
if not _supports_upload(provider): if not _supports_upload(plugin):
debug(f"[provider] Provider '{name}' does not support upload") debug(f"[plugin] Plugin '{name}' does not support upload")
return None return None
return provider # type: ignore[return-value] return plugin # type: ignore[return-value]
def list_file_providers(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]: def list_upload_plugins(config: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
availability: Dict[str, bool] = {} availability: Dict[str, bool] = {}
for info in REGISTRY.iter_providers(): for info in REGISTRY.iter_providers():
try: try:
provider = info.provider_class(config) plugin = info.provider_class(config)
availability[info.canonical_name] = bool( availability[info.canonical_name] = bool(
provider.validate() and info.supports_upload plugin.validate() and info.supports_upload
) )
except Exception: except Exception:
availability[info.canonical_name] = False availability[info.canonical_name] = False
return availability return availability
def match_provider_name_for_url(url: str) -> Optional[str]: def match_plugin_name_for_url(url: str) -> Optional[str]:
raw_url = str(url or "").strip() raw_url = str(url or "").strip()
raw_url_lower = raw_url.lower() raw_url_lower = raw_url.lower()
try: try:
@@ -540,31 +649,31 @@ def match_provider_name_for_url(url: str) -> Optional[str]:
return None return None
def provider_inline_query_choices( def plugin_inline_query_choices(
provider_name: str, plugin_name: str,
field_name: str, field_name: str,
config: Optional[Dict[str, Any]] = None, config: Optional[Dict[str, Any]] = None,
) -> List[str]: ) -> List[str]:
"""Return provider-declared inline query choices for a field (e.g., system:GBA). """Return plugin-declared inline query choices for a field (e.g., system:GBA).
Providers can expose a mapping via ``QUERY_ARG_CHOICES`` (preferred) or Plugins can expose a mapping via ``QUERY_ARG_CHOICES`` (preferred) or
``INLINE_QUERY_FIELD_CHOICES`` / ``inline_query_field_choices()``. The helper ``INLINE_QUERY_FIELD_CHOICES`` / ``inline_query_field_choices()``. The helper
keeps completion logic simple and reusable. keeps completion logic simple and reusable.
""" """
pname = str(provider_name or "").strip().lower() pname = str(plugin_name or "").strip().lower()
field = str(field_name or "").strip().lower() field = str(field_name or "").strip().lower()
if not pname or not field: if not pname or not field:
return [] return []
provider = get_search_provider(pname, config) plugin = get_search_plugin(pname, config)
if provider is None: if plugin is None:
provider = get_provider(pname, config) plugin = get_plugin(pname, config)
if provider is None: if plugin is None:
return [] return []
try: try:
mapping = _collect_inline_choice_mapping(provider) mapping = _collect_inline_choice_mapping(plugin)
if not mapping: if not mapping:
return [] return []
@@ -593,12 +702,32 @@ def provider_inline_query_choices(
return [] return []
def get_provider_for_url(url: str, def get_plugin_for_url(url: str,
config: Optional[Dict[str, Any]] = None) -> Optional[Provider]: config: Optional[Dict[str, Any]] = None) -> Optional[Provider]:
name = match_provider_name_for_url(url) name = match_plugin_name_for_url(url)
if not name: if not name:
return None return None
return get_provider(name, config) return get_plugin(name, config)
def list_selection_url_prefixes() -> List[str]:
prefixes: List[str] = []
seen: set[str] = set()
for info in REGISTRY.iter_providers():
try:
values = info.provider_class.selection_url_prefixes()
except Exception:
values = ()
for value in values or ():
try:
normalized = str(value or "").strip().lower()
except Exception:
continue
if not normalized or normalized in seen:
continue
seen.add(normalized)
prefixes.append(normalized)
return prefixes
def resolve_inline_filters( def resolve_inline_filters(
@@ -657,21 +786,25 @@ def resolve_inline_filters(
__all__ = [ __all__ = [
"ProviderInfo", "ProviderInfo",
"PluginInfo",
"Provider", "Provider",
"SearchProvider", "SearchProvider",
"FileProvider", "FileProvider",
"SearchResult", "SearchResult",
"register_provider", "PluginRegistry",
"get_provider", "PLUGIN_REGISTRY",
"list_providers", "register_plugin",
"get_search_provider", "get_plugin",
"list_search_providers", "list_plugins",
"get_file_provider", "get_search_plugin",
"list_file_providers", "list_search_plugins",
"match_provider_name_for_url", "get_upload_plugin",
"get_provider_for_url", "list_upload_plugins",
"get_provider_class", "match_plugin_name_for_url",
"get_plugin_for_url",
"list_selection_url_prefixes",
"get_plugin_class",
"selection_auto_stage_for_table", "selection_auto_stage_for_table",
"download_soulseek_file", "plugin_inline_query_choices",
"provider_inline_query_choices", "is_known_plugin_name",
] ]
+15 -15
View File
@@ -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.registry import get_plugin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
@@ -370,22 +371,21 @@ def get_cmdlet_arg_choices(
token = matrix_conf.get("access_token") token = matrix_conf.get("access_token")
if hs and token: if hs and token:
try: try:
from Provider.matrix import Matrix provider = get_plugin("matrix", config)
if provider is not None:
try: try:
m = Matrix(config) rooms = provider.list_rooms(room_ids=ids)
rooms = m.list_rooms(room_ids=ids) choices = []
choices = [] for r in rooms or []:
for r in rooms or []: name = str(r.get("name") or "").strip()
name = str(r.get("name") or "").strip() rid = str(r.get("room_id") or "").strip()
rid = str(r.get("room_id") or "").strip() choices.append(name or rid)
choices.append(name or rid) if choices:
if choices: return choices
return choices except Exception as exc:
except Exception as exc: logger.exception("Matrix provider failed while listing rooms: %s", exc)
logger.exception("Matrix provider failed while listing rooms: %s", exc)
except Exception as exc: except Exception as exc:
logger.exception("Failed to import Matrix provider or initialize: %s", exc) logger.exception("Failed to initialize Matrix plugin: %s", exc)
except Exception as exc: except Exception as exc:
logger.exception("Failed to resolve matrix rooms: %s", exc) logger.exception("Failed to resolve matrix rooms: %s", exc)
+16 -6
View File
@@ -90,10 +90,10 @@ class SharedArgs:
description="http parser", description="http parser",
) )
PROVIDER = CmdletArg( PLUGIN = CmdletArg(
name="provider", name="plugin",
type="string", type="string",
description="selects provider", description="selects plugin",
) )
@staticmethod @staticmethod
@@ -284,7 +284,13 @@ class Cmdlet:
return {f"-{arg_name}", f"--{arg_name}"} return {f"-{arg_name}", f"--{arg_name}"}
def build_flag_registry(self) -> Dict[str, set[str]]: def build_flag_registry(self) -> Dict[str, set[str]]:
return {arg.name: self.get_flags(arg.name) for arg in self.arg} registry: Dict[str, set[str]] = {}
for arg in self.arg:
try:
registry[arg.name] = {str(flag).lower() for flag in arg.to_flags()}
except Exception:
registry[arg.name] = {flag.lower() for flag in self.get_flags(arg.name)}
return registry
def parse_cmdlet_args( def parse_cmdlet_args(
@@ -335,8 +341,12 @@ def parse_cmdlet_args(
positional_args.append(spec) positional_args.append(spec)
arg_spec_map[canonical_key] = canonical_name arg_spec_map[canonical_key] = canonical_name
arg_spec_map[f"-{canonical_name}".lower()] = canonical_name try:
arg_spec_map[f"--{canonical_name}".lower()] = canonical_name for flag in spec.to_flags():
arg_spec_map[str(flag).lower()] = canonical_name
except Exception:
arg_spec_map[f"-{canonical_name}".lower()] = canonical_name
arg_spec_map[f"--{canonical_name}".lower()] = canonical_name
i = 0 i = 0
positional_index = 0 positional_index = 0
+10 -29
View File
@@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
from ProviderCore.registry import get_plugin
from SYS.yt_metadata import extract_ytdlp_tags from SYS.yt_metadata import extract_ytdlp_tags
try: # Optional; used when available for richer metadata fetches try: # Optional; used when available for richer metadata fetches
@@ -2213,40 +2214,20 @@ def enrich_playlist_entries(entries: list, extractor: str) -> list:
Returns: Returns:
List of enriched entry dicts List of enriched entry dicts
""" """
# Import here to avoid circular dependency
from tool.ytdlp import is_url_supported_by_ytdlp
if not entries: if not entries:
return entries return entries
enriched = [] plugin = get_plugin("ytdlp", {})
for entry in entries: if plugin is None:
# If entry has a direct URL, fetch its full metadata return entries
entry_url = entry.get("url")
if entry_url and is_url_supported_by_ytdlp(entry_url):
try:
import yt_dlp
ydl_opts: Any = { try:
"quiet": True, enriched = plugin.enrich_playlist_entries(entries, extractor=extractor)
"no_warnings": True, except Exception:
"skip_download": True, logger.exception("Failed to enrich playlist entries for extractor: %s", extractor)
"noprogress": True, return entries
"socket_timeout": 5,
"retries": 1,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
full_info = ydl.extract_info(entry_url, download=False)
if full_info:
enriched.append(full_info)
continue
except Exception:
logger.exception("Failed to fetch full metadata for entry URL: %s", entry_url)
# Fallback to original entry if fetch failed return enriched if isinstance(enriched, list) else entries
enriched.append(entry)
return enriched
def format_playlist_entry(entry: Dict[str, def format_playlist_entry(entry: Dict[str,
+126 -79
View File
@@ -1505,9 +1505,9 @@ class PipelineExecutor:
"table") else None "table") else None
) )
# Prefer an explicit provider hint from table metadata when available. # Prefer an explicit plugin hint from table metadata when available.
# This keeps @N selectors working even when row payloads don't carry a # This keeps @N selectors working even when row payloads don't carry a
# provider key (or when they carry a table-type like tidal.album). # plugin key (or when they carry a table-type like tidal.album).
try: try:
meta = ( meta = (
current_table.get_table_metadata() current_table.get_table_metadata()
@@ -1517,56 +1517,58 @@ class PipelineExecutor:
except Exception: except Exception:
meta = None meta = None
if isinstance(meta, dict): if isinstance(meta, dict):
_add(meta.get("plugin"))
_add(meta.get("provider")) _add(meta.get("provider"))
except Exception: except Exception:
logger.exception("Failed to inspect current_table/table metadata in _maybe_run_class_selector") logger.exception("Failed to inspect current_table/table metadata in _maybe_run_class_selector")
for item in selected_items or []: for item in selected_items or []:
if isinstance(item, dict): if isinstance(item, dict):
_add(item.get("plugin"))
_add(item.get("provider")) _add(item.get("provider"))
_add(item.get("store")) _add(item.get("store"))
_add(item.get("table")) _add(item.get("table"))
else: else:
_add(getattr(item, "plugin", None))
_add(getattr(item, "provider", None)) _add(getattr(item, "provider", None))
_add(getattr(item, "store", None)) _add(getattr(item, "store", None))
_add(getattr(item, "table", None)) _add(getattr(item, "table", None))
try: try:
from ProviderCore.registry import get_provider, is_known_provider_name from ProviderCore.registry import get_plugin, is_known_plugin_name
except Exception: except Exception:
get_provider = None # type: ignore get_plugin = None # type: ignore
is_known_provider_name = None # type: ignore is_known_plugin_name = None # type: ignore
# If we have a table-type like "tidal.album", also try its provider prefix ("tidal") # If we have a table-type like "tidal.album", also try its plugin prefix ("tidal")
# when that prefix is a registered provider name. # when that prefix is a registered plugin name.
if is_known_provider_name is not None: if is_known_plugin_name is not None:
try: try:
for key in list(candidates): for key in list(candidates):
if not isinstance(key, str): if not isinstance(key, str):
continue continue
if "." not in key: if "." not in key:
continue continue
if is_known_provider_name(key): if is_known_plugin_name(key):
continue continue
prefix = str(key).split(".", 1)[0].strip().lower() prefix = str(key).split(".", 1)[0].strip().lower()
if prefix and is_known_provider_name(prefix): if prefix and is_known_plugin_name(prefix):
_add(prefix) _add(prefix)
except Exception: except Exception:
logger.exception("Failed while computing provider prefix heuristics in _maybe_run_class_selector") logger.exception("Failed while computing plugin prefix heuristics in _maybe_run_class_selector")
if get_provider is not None: if get_plugin is not None:
for key in candidates: for key in candidates:
try: try:
if is_known_provider_name is not None and ( if is_known_plugin_name is not None and (
not is_known_provider_name(key)): not is_known_plugin_name(key)):
continue continue
except Exception: except Exception:
# If the predicate fails for any reason, fall back to legacy behavior. logger.exception("is_known_plugin_name predicate failed for key %s; falling back", key)
logger.exception("is_known_provider_name predicate failed for key %s; falling back", key)
try: try:
provider = get_provider(key, config) provider = get_plugin(key, config)
except Exception as exc: except Exception as exc:
logger.exception("Failed to load provider '%s' during selector resolution: %s", key, exc) logger.exception("Failed to load plugin '%s' during selector resolution: %s", key, exc)
continue continue
selector = getattr(provider, "selector", None) selector = getattr(provider, "selector", None)
if selector is None: if selector is None:
@@ -1583,6 +1585,92 @@ class PipelineExecutor:
if handled: if handled:
return True return True
@staticmethod
def _maybe_expand_plugin_selection(
selected_items: List[Any],
*,
ctx: Any,
config: Dict[str, Any],
stage_table: Any,
) -> Optional[List[Any]]:
candidates: list[str] = []
def _add(value: Any) -> None:
text = str(value or "").strip().lower()
if text and text not in candidates:
candidates.append(text)
table_type = None
try:
table_type = stage_table.table if stage_table is not None and hasattr(stage_table, "table") else None
except Exception:
table_type = None
_add(table_type)
try:
meta = (
stage_table.get_table_metadata()
if stage_table is not None and hasattr(stage_table, "get_table_metadata")
else getattr(stage_table, "table_metadata", None)
)
except Exception:
meta = None
if isinstance(meta, dict):
_add(meta.get("plugin"))
_add(meta.get("provider"))
for item in selected_items or []:
if isinstance(item, dict):
_add(item.get("plugin"))
_add(item.get("provider"))
_add(item.get("table"))
_add(item.get("source"))
else:
_add(getattr(item, "plugin", None))
_add(getattr(item, "provider", None))
_add(getattr(item, "table", None))
_add(getattr(item, "source", None))
try:
from ProviderCore.registry import get_plugin, is_known_plugin_name
except Exception:
return None
for key in list(candidates):
if "." in key:
prefix = str(key).split(".", 1)[0].strip().lower()
if prefix and prefix not in candidates:
candidates.append(prefix)
for key in candidates:
try:
if not is_known_plugin_name(key):
continue
except Exception:
continue
try:
plugin = get_plugin(key, config)
except Exception:
continue
if plugin is None:
continue
expand = getattr(plugin, "expand_selection", None)
if not callable(expand):
continue
try:
expanded = expand(
selected_items,
ctx=ctx,
stage_is_last=False,
table_type=str(table_type or ""),
)
except Exception:
logger.exception("%s expand_selection failed", key)
return None
if expanded:
return list(expanded)
return None
store_keys: list[str] = [] store_keys: list[str] = []
for item in selected_items or []: for item in selected_items or []:
if isinstance(item, dict): if isinstance(item, dict):
@@ -1998,10 +2086,10 @@ class PipelineExecutor:
# IMPORTANT: Put selected row args *before* source_args. # IMPORTANT: Put selected row args *before* source_args.
# Rationale: The cmdlet argument parser treats the *first* unknown # Rationale: The cmdlet argument parser treats the *first* unknown
# token as a positional value (e.g., URL). If `source_args` # token as a positional value (e.g., URL). If `source_args`
# contain unknown flags (like -provider which download-file does # contain unknown flags (like a removed legacy flag that download-file does
# not declare), they could be misinterpreted as the positional # not declare), they could be misinterpreted as the positional
# URL argument and cause attempts to download strings like # URL argument and cause attempts to download strings like
# "-provider" (which is invalid). By placing selection args # not accept). By placing selection args
# first we ensure the intended URL/selection token is parsed # first we ensure the intended URL/selection token is parsed
# as the positional URL and avoid this class of parsing errors. # as the positional URL and avoid this class of parsing errors.
expanded_stage: List[str] = cmd_list + selected_row_args + source_args expanded_stage: List[str] = cmd_list + selected_row_args + source_args
@@ -2081,66 +2169,15 @@ class PipelineExecutor:
print("No items matched selection in pipeline\n") print("No items matched selection in pipeline\n")
return False, None return False, None
# Provider selection expansion (non-terminal): allow certain provider tables if stages:
# (e.g. tidal.album) to expand to multiple downstream items when the user expanded = PipelineExecutor._maybe_expand_plugin_selection(
# pipes into another stage (e.g. @N | .mpv or @N | add-file). filtered,
table_type_hint = None ctx=ctx,
try: config=config,
table_type_hint = ( stage_table=stage_table,
stage_table.table
if stage_table is not None and hasattr(stage_table, "table")
else None
) )
except Exception: if expanded:
table_type_hint = None filtered = expanded
if stages and isinstance(table_type_hint, str) and table_type_hint.strip().lower() == "tidal.album":
try:
from ProviderCore.registry import get_provider
prov = get_provider("tidal", config)
except Exception:
prov = None
if prov is not None and hasattr(prov, "_extract_album_selection_context") and hasattr(prov, "_tracks_for_album"):
try:
album_contexts = prov._extract_album_selection_context(filtered) # type: ignore[attr-defined]
except Exception:
album_contexts = []
track_items: List[Any] = []
seen_track_ids: set[int] = set()
for album_id, album_title, artist_name in album_contexts or []:
try:
track_results = prov._tracks_for_album( # type: ignore[attr-defined]
album_id=album_id,
album_title=album_title,
artist_name=artist_name,
limit=500,
)
except Exception:
track_results = []
for tr in track_results or []:
try:
md = getattr(tr, "full_metadata", None)
tid = None
if isinstance(md, dict):
raw_id = md.get("trackId") or md.get("id")
try:
tid = int(raw_id) if raw_id is not None else None
except Exception:
tid = None
if tid is not None:
if tid in seen_track_ids:
continue
seen_track_ids.add(tid)
except Exception:
logger.exception("Failed to extract/parse track metadata in album processing")
track_items.append(tr)
if track_items:
filtered = track_items
table_type_hint = "tidal.track"
if PipelineExecutor._maybe_run_class_selector( if PipelineExecutor._maybe_run_class_selector(
ctx, ctx,
@@ -2177,6 +2214,16 @@ class PipelineExecutor:
except Exception: except Exception:
logger.exception("Failed to determine current_table for selection auto-insert; defaulting to None") logger.exception("Failed to determine current_table for selection auto-insert; defaulting to None")
current_table = None current_table = None
table_type_hint = None
try:
raw_table_type = (
stage_table.table
if stage_table is not None and hasattr(stage_table, "table") else None
)
if isinstance(raw_table_type, str) and raw_table_type.strip():
table_type_hint = raw_table_type
except Exception:
table_type_hint = None
table_type = None table_type = None
try: try:
if isinstance(table_type_hint, str) and table_type_hint.strip(): if isinstance(table_type_hint, str) and table_type_hint.strip():
+23 -29
View File
@@ -1,10 +1,4 @@
"""Provider registry for ResultTable API (breaking, strict API). """Plugin registry for the strict ResultTable API."""
Providers register themselves here with an adapter and optional column factory
and selection function. Consumers (cmdlets) can look up providers by name and
obtain the columns and selection behavior for building tables and for selection
args used by subsequent cmdlets.
"""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
@@ -18,7 +12,7 @@ SelectionFn = Callable[[ResultModel], List[str]]
@dataclass @dataclass
class Provider: class Plugin:
name: str name: str
adapter: ProviderAdapter adapter: ProviderAdapter
# columns can be a static list or a factory that derives columns from sample rows # columns can be a static list or a factory that derives columns from sample rows
@@ -28,7 +22,7 @@ class Provider:
def get_columns(self, rows: Optional[Iterable[ResultModel]] = None) -> List[ColumnSpec]: def get_columns(self, rows: Optional[Iterable[ResultModel]] = None) -> List[ColumnSpec]:
if self.columns is None: if self.columns is None:
raise ValueError(f"provider '{self.name}' must define columns") raise ValueError(f"plugin '{self.name}' must define columns")
if callable(self.columns): if callable(self.columns):
rows_list = list(rows) if rows is not None else [] rows_list = list(rows) if rows is not None else []
@@ -37,13 +31,13 @@ class Provider:
cols = list(self.columns) cols = list(self.columns)
if not cols: if not cols:
raise ValueError(f"provider '{self.name}' produced no columns") raise ValueError(f"plugin '{self.name}' produced no columns")
return cols return cols
def selection_args(self, row: ResultModel) -> List[str]: def selection_args(self, row: ResultModel) -> List[str]:
if not callable(self.selection_fn): if not callable(self.selection_fn):
raise ValueError(f"provider '{self.name}' must define a selection function") raise ValueError(f"plugin '{self.name}' must define a selection function")
sel = list(self.selection_fn(ensure_result_model(row))) sel = list(self.selection_fn(ensure_result_model(row)))
return sel return sel
@@ -54,7 +48,7 @@ class Provider:
try: try:
rows = [ensure_result_model(r) for r in self.adapter(items)] rows = [ensure_result_model(r) for r in self.adapter(items)]
except Exception as exc: except Exception as exc:
raise RuntimeError(f"provider '{self.name}' adapter failed") from exc raise RuntimeError(f"plugin '{self.name}' adapter failed") from exc
cols = self.get_columns(rows) cols = self.get_columns(rows)
return ResultTable(provider=self.name, rows=rows, columns=cols, meta=self.metadata or {}) return ResultTable(provider=self.name, rows=rows, columns=cols, meta=self.metadata or {})
@@ -82,37 +76,37 @@ class Provider:
return [self.serialize_row(r) for r in rows] return [self.serialize_row(r) for r in rows]
_PROVIDERS: Dict[str, Provider] = {} _PLUGINS: Dict[str, Plugin] = {}
def register_provider( def register_plugin(
name: str, name: str,
adapter: ProviderAdapter, adapter: ProviderAdapter,
*, *,
columns: Union[List[ColumnSpec], ColumnFactory], columns: Union[List[ColumnSpec], ColumnFactory],
selection_fn: SelectionFn, selection_fn: SelectionFn,
metadata: Optional[Dict[str, Any]] = None, metadata: Optional[Dict[str, Any]] = None,
) -> Provider: ) -> Plugin:
name = str(name or "").strip().lower() name = str(name or "").strip().lower()
if not name: if not name:
raise ValueError("provider name required") raise ValueError("plugin name required")
if name in _PROVIDERS: if name in _PLUGINS:
raise ValueError(f"provider already registered: {name}") raise ValueError(f"plugin already registered: {name}")
if columns is None: if columns is None:
raise ValueError("provider registration requires columns") raise ValueError("plugin registration requires columns")
if selection_fn is None: if selection_fn is None:
raise ValueError("provider registration requires selection_fn") raise ValueError("plugin registration requires selection_fn")
p = Provider(name=name, adapter=adapter, columns=columns, selection_fn=selection_fn, metadata=metadata) plugin = Plugin(name=name, adapter=adapter, columns=columns, selection_fn=selection_fn, metadata=metadata)
_PROVIDERS[name] = p _PLUGINS[name] = plugin
return p return plugin
def get_provider(name: str) -> Provider: def get_plugin(name: str) -> Plugin:
normalized = str(name or "").lower() normalized = str(name or "").lower()
if normalized not in _PROVIDERS: if normalized not in _PLUGINS:
raise KeyError(f"provider not registered: {name}") raise KeyError(f"plugin not registered: {name}")
return _PROVIDERS[normalized] return _PLUGINS[normalized]
def list_providers() -> List[str]: def list_plugins() -> List[str]:
return list(_PROVIDERS.keys()) return list(_PLUGINS.keys())
+3 -3
View File
@@ -148,7 +148,7 @@ def show_store_config_panel(
def show_available_providers_panel(provider_names: List[str]) -> None: def show_available_providers_panel(provider_names: List[str]) -> None:
"""Show a Rich panel listing available/configured providers.""" """Show a Rich panel listing available/configured plugins."""
from rich.columns import Columns from rich.columns import Columns
from rich.console import Group from rich.console import Group
@@ -164,13 +164,13 @@ def show_available_providers_panel(provider_names: List[str]) -> None:
) )
group = Group( group = Group(
Text("The following providers are configured and ready to use:\n"), Text("The following plugins are configured and ready to use:\n"),
cols cols
) )
panel = Panel( panel = Panel(
group, group,
title="[bold green]Configured Providers[/bold green]", title="[bold green]Configured Plugins[/bold green]",
border_style="green", border_style="green",
padding=(1, 2) padding=(1, 2)
) )
+9 -5
View File
@@ -526,9 +526,6 @@ class HydrusNetwork(Store):
# Upload file if not already present # Upload file if not already present
if not file_exists: if not file_exists:
debug(
f"{self._log_prefix()} Uploading: {file_path.name}"
)
response = client.add_file(file_path) response = client.add_file(file_path)
# Extract hash from response # Extract hash from response
@@ -553,8 +550,15 @@ class HydrusNetwork(Store):
hydrus_hash = None hydrus_hash = None
if not hydrus_hash or len(str(hydrus_hash)) != 64: if not hydrus_hash or len(str(hydrus_hash)) != 64:
debug( debug_panel(
f"{self._log_prefix()} Hydrus response hash missing/invalid; using precomputed hash" "Hydrus upload fallback",
[
("store", self.NAME),
("file", file_path.name),
("reason", "response hash missing/invalid"),
("fallback_hash", file_hash),
],
border_style="yellow",
) )
hydrus_hash = file_hash hydrus_hash = file_hash
+2 -2
View File
@@ -613,14 +613,14 @@ class PipelineHubApp(App):
# Run startup check automatically # Run startup check automatically
self._run_pipeline_background(".status") self._run_pipeline_background(".status")
# Provide a visible startup summary of configured providers/stores for debugging # Provide a visible startup summary of configured plugins/stores for debugging
try: try:
cfg = load_config() or {} cfg = load_config() or {}
provs = list(cfg.get("provider", {}).keys()) if isinstance(cfg.get("provider"), dict) else [] provs = list(cfg.get("provider", {}).keys()) if isinstance(cfg.get("provider"), dict) else []
stores = list(cfg.get("store", {}).keys()) if isinstance(cfg.get("store"), dict) else [] stores = list(cfg.get("store", {}).keys()) if isinstance(cfg.get("store"), dict) else []
prov_display = ", ".join(provs[:10]) + ("..." if len(provs) > 10 else "") prov_display = ", ".join(provs[:10]) + ("..." if len(provs) > 10 else "")
store_display = ", ".join(stores[:10]) + ("..." if len(stores) > 10 else "") store_display = ", ".join(stores[:10]) + ("..." if len(stores) > 10 else "")
self._append_log_line(f"Startup config: providers={len(provs)} ({prov_display or '(none)'}), stores={len(stores)} ({store_display or '(none)'}), db={db.db_path.name}") self._append_log_line(f"Startup config: plugins={len(provs)} ({prov_display or '(none)'}), stores={len(stores)} ({store_display or '(none)'}), db={db.db_path.name}")
except Exception: except Exception:
logger.exception("Failed to produce startup config summary") logger.exception("Failed to produce startup config summary")
+26 -25
View File
@@ -23,7 +23,7 @@ from SYS.config import (
from SYS.database import db from SYS.database import db
from SYS.logger import log, debug from SYS.logger import log, debug
from Store.registry import _discover_store_classes, _required_keys_for from Store.registry import _discover_store_classes, _required_keys_for
from ProviderCore.registry import list_providers from ProviderCore.registry import get_plugin, list_plugins
from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker from TUI.modalscreen.matrix_room_picker import MatrixRoomPicker
from TUI.modalscreen.selection_modal import SelectionModal from TUI.modalscreen.selection_modal import SelectionModal
import logging import logging
@@ -177,7 +177,7 @@ class ConfigModal(ModalScreen):
with ListView(id="category-list"): with ListView(id="category-list"):
yield ListItem(Label("Global Settings"), id="cat-globals") yield ListItem(Label("Global Settings"), id="cat-globals")
yield ListItem(Label("Stores"), id="cat-stores") yield ListItem(Label("Stores"), id="cat-stores")
yield ListItem(Label("Providers"), id="cat-providers") yield ListItem(Label("Plugins"), id="cat-providers")
yield ListItem(Label("Tools"), id="cat-tools") yield ListItem(Label("Tools"), id="cat-tools")
with Vertical(id="config-content"): with Vertical(id="config-content"):
@@ -187,7 +187,7 @@ class ConfigModal(ModalScreen):
# Durable synchronous save: waits and verifies DB persisted critical keys # Durable synchronous save: waits and verifies DB persisted critical keys
yield Button("Save (durable)", variant="primary", id="save-durable-btn") yield Button("Save (durable)", variant="primary", id="save-durable-btn")
yield Button("Add Store", variant="primary", id="add-store-btn") yield Button("Add Store", variant="primary", id="add-store-btn")
yield Button("Add Provider", variant="primary", id="add-provider-btn") yield Button("Add Plugin", variant="primary", id="add-provider-btn")
yield Button("Add Tool", variant="primary", id="add-tool-btn") yield Button("Add Tool", variant="primary", id="add-tool-btn")
yield Button("Back", id="back-btn") yield Button("Back", id="back-btn")
yield Button("Close", variant="error", id="cancel-btn") yield Button("Close", variant="error", id="cancel-btn")
@@ -381,10 +381,10 @@ class ConfigModal(ModalScreen):
container.mount(row) container.mount(row)
def render_providers(self, container: ScrollableContainer) -> None: def render_providers(self, container: ScrollableContainer) -> None:
container.mount(Label("Configured Providers", classes="config-label")) container.mount(Label("Configured Plugins", classes="config-label"))
providers = self.config_data.get("provider", {}) providers = self.config_data.get("provider", {})
if not providers: if not providers:
container.mount(Static("No providers configured.")) container.mount(Static("No plugins configured."))
else: else:
for i, (name, _) in enumerate(providers.items()): for i, (name, _) in enumerate(providers.items()):
edit_id = f"edit-provider-{i}" edit_id = f"edit-provider-{i}"
@@ -448,9 +448,9 @@ class ConfigModal(ModalScreen):
# Fetch Provider schema # Fetch Provider schema
if item_type == "provider": if item_type == "provider":
from ProviderCore.registry import get_provider_class from ProviderCore.registry import get_plugin_class
try: try:
pcls = get_provider_class(item_name) pcls = get_plugin_class(item_name)
if pcls and hasattr(pcls, "config_schema") and callable(pcls.config_schema): if pcls and hasattr(pcls, "config_schema") and callable(pcls.config_schema):
for field_def in pcls.config_schema(): for field_def in pcls.config_schema():
k = field_def.get("key") k = field_def.get("key")
@@ -625,9 +625,9 @@ class ConfigModal(ModalScreen):
# If it's a provider, we might have required keys (legacy check fallback) # If it's a provider, we might have required keys (legacy check fallback)
if item_type == "provider": if item_type == "provider":
# 2. Legacy required_config_keys # 2. Legacy required_config_keys
from ProviderCore.registry import get_provider_class from ProviderCore.registry import get_plugin_class
try: try:
pcls = get_provider_class(item_name) pcls = get_plugin_class(item_name)
if pcls: if pcls:
required_keys = pcls.required_config_keys() required_keys = pcls.required_config_keys()
for rk in required_keys: for rk in required_keys:
@@ -886,18 +886,18 @@ class ConfigModal(ModalScreen):
logger.exception("Failed to inspect store class config_schema for '%s'", stype) logger.exception("Failed to inspect store class config_schema for '%s'", stype)
self.app.push_screen(SelectionModal("Select Store Type", options), callback=self.on_store_type_selected) self.app.push_screen(SelectionModal("Select Store Type", options), callback=self.on_store_type_selected)
elif bid == "add-provider-btn": elif bid == "add-provider-btn":
provider_names = list(list_providers().keys()) provider_names = list(list_plugins().keys())
options = [] options = []
from ProviderCore.registry import get_provider_class from ProviderCore.registry import get_plugin_class
for ptype in provider_names: for ptype in provider_names:
pcls = get_provider_class(ptype) pcls = get_plugin_class(ptype)
if pcls and hasattr(pcls, "config_schema") and callable(pcls.config_schema): if pcls and hasattr(pcls, "config_schema") and callable(pcls.config_schema):
try: try:
if pcls.config_schema(): if pcls.config_schema():
options.append(ptype) options.append(ptype)
except Exception: except Exception:
logger.exception("Failed to inspect provider class config_schema for '%s'", ptype) logger.exception("Failed to inspect provider class config_schema for '%s'", ptype)
self.app.push_screen(SelectionModal("Select Provider Type", options), callback=self.on_provider_type_selected) self.app.push_screen(SelectionModal("Select Plugin Type", options), callback=self.on_provider_type_selected)
elif bid == "add-tool-btn": elif bid == "add-tool-btn":
# Discover tool modules that advertise a config_schema() # Discover tool modules that advertise a config_schema()
options = [] options = []
@@ -1067,9 +1067,9 @@ class ConfigModal(ModalScreen):
# For providers, they are usually top-level entries in 'provider' dict # For providers, they are usually top-level entries in 'provider' dict
if ptype not in self.config_data["provider"]: if ptype not in self.config_data["provider"]:
from ProviderCore.registry import get_provider_class from ProviderCore.registry import get_plugin_class
try: try:
pcls = get_provider_class(ptype) pcls = get_plugin_class(ptype)
new_config = {} new_config = {}
if pcls: if pcls:
# Use schema for defaults # Use schema for defaults
@@ -1273,9 +1273,9 @@ class ConfigModal(ModalScreen):
@work(thread=True) @work(thread=True)
def _matrix_test_background(self) -> None: def _matrix_test_background(self) -> None:
try: try:
from Provider.matrix import Matrix provider = get_plugin("matrix", self.config_data)
if provider is None:
provider = Matrix(self.config_data) raise RuntimeError("Matrix plugin unavailable")
rooms = provider.list_rooms() rooms = provider.list_rooms()
self.app.call_from_thread(self._matrix_test_result, True, rooms, None) self.app.call_from_thread(self._matrix_test_result, True, rooms, None)
except Exception as exc: except Exception as exc:
@@ -1433,9 +1433,9 @@ class ConfigModal(ModalScreen):
@work(thread=True) @work(thread=True)
def _matrix_load_background(self) -> None: def _matrix_load_background(self) -> None:
try: try:
from Provider.matrix import Matrix provider = get_plugin("matrix", self.config_data)
if provider is None:
provider = Matrix(self.config_data) raise RuntimeError("Matrix plugin unavailable")
rooms = provider.list_rooms() rooms = provider.list_rooms()
self.app.call_from_thread(self._matrix_load_result, True, rooms, None) self.app.call_from_thread(self._matrix_load_result, True, rooms, None)
except Exception as exc: except Exception as exc:
@@ -1626,8 +1626,9 @@ class ConfigModal(ModalScreen):
return [] return []
try: try:
from Provider.matrix import Matrix provider = get_plugin("matrix", self.config_data)
provider = Matrix(self.config_data) if provider is None:
return []
rooms = provider.list_rooms(room_ids=ids_list) rooms = provider.list_rooms(room_ids=ids_list)
return rooms or [] return rooms or []
except Exception as exc: except Exception as exc:
@@ -1870,9 +1871,9 @@ class ConfigModal(ModalScreen):
required_keys = list(_required_keys_for(classes[stype])) required_keys = list(_required_keys_for(classes[stype]))
section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {}) section = self.config_data.get("store", {}).get(stype, {}).get(item_name, {})
elif item_type == "provider": elif item_type == "provider":
from ProviderCore.registry import get_provider_class from ProviderCore.registry import get_plugin_class
try: try:
pcls = get_provider_class(item_name) pcls = get_plugin_class(item_name)
if pcls: if pcls:
# Collect required keys from schema # Collect required keys from schema
if hasattr(pcls, "config_schema") and callable(pcls.config_schema): if hasattr(pcls, "config_schema") and callable(pcls.config_schema):
+4 -2
View File
@@ -8,6 +8,7 @@ from textual.screen import ModalScreen
from textual.widgets import Static, Button, Checkbox, ListView, ListItem from textual.widgets import Static, Button, Checkbox, ListView, ListItem
from textual import work from textual import work
from rich.text import Text from rich.text import Text
from ProviderCore.registry import get_plugin
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -181,8 +182,9 @@ class MatrixRoomPicker(ModalScreen[List[str]]):
@work(thread=True) @work(thread=True)
def _load_rooms_background(self) -> None: def _load_rooms_background(self) -> None:
try: try:
from Provider.matrix import Matrix provider = get_plugin("matrix", self.config)
provider = Matrix(self.config) if provider is None:
raise RuntimeError("Matrix plugin unavailable")
rooms = provider.list_rooms() rooms = provider.list_rooms()
self.app.call_from_thread(self._apply_room_results, rooms, None) self.app.call_from_thread(self._apply_room_results, rooms, None)
except Exception as exc: except Exception as exc:
+3 -3
View File
@@ -16,7 +16,7 @@ import asyncio
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from SYS.config import load_config, resolve_output_dir from SYS.config import load_config, resolve_output_dir
from SYS.result_table import Table from SYS.result_table import Table
from ProviderCore.registry import get_search_provider from ProviderCore.registry import get_search_plugin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -174,7 +174,7 @@ class SearchModal(ModalScreen):
self.current_worker.log_step(f"Connecting to {source}...") self.current_worker.log_step(f"Connecting to {source}...")
try: try:
provider = get_search_provider(source) provider = get_search_plugin(source)
if not provider: if not provider:
logger.error(f"[search-modal] Provider not available: {source}") logger.error(f"[search-modal] Provider not available: {source}")
if self.current_worker: if self.current_worker:
@@ -380,7 +380,7 @@ class SearchModal(ModalScreen):
config = load_config() config = load_config()
output_dir = resolve_output_dir(config) output_dir = resolve_output_dir(config)
provider = get_search_provider("openlibrary", config=config) provider = get_search_plugin("openlibrary", config=config)
if not provider: if not provider:
logger.error("[search-modal] Provider not available: openlibrary") logger.error("[search-modal] Provider not available: openlibrary")
return return
+18 -21
View File
@@ -199,10 +199,10 @@ class SharedArgs:
type="string", type="string",
description="http parser", description="http parser",
) )
PROVIDER = CmdletArg( PLUGIN = CmdletArg(
name="provider", name="plugin",
type="string", type="string",
description="selects provider", description="selects plugin",
) )
@staticmethod @staticmethod
@@ -538,10 +538,13 @@ class Cmdlet:
elif low in flags.get('tag', set()): elif low in flags.get('tag', set()):
# handle tag # handle tag
""" """
return { registry: Dict[str, set[str]] = {}
arg.name: self.get_flags(arg.name) for arg in self.arg:
for arg in self.arg try:
} registry[arg.name] = {str(flag).lower() for flag in arg.to_flags()}
except Exception:
registry[arg.name] = {flag.lower() for flag in self.get_flags(arg.name)}
return registry
# Tag groups cache (loaded from JSON config file) # Tag groups cache (loaded from JSON config file)
@@ -642,10 +645,14 @@ def parse_cmdlet_args(args: Sequence[str],
else: else:
flagged_args.append(spec) flagged_args.append(spec)
# Register all prefix variants for flagged lookup # Register all supported flag variants, including legacy aliases.
arg_spec_map[canonical_name.lower()] = canonical_name # bare name arg_spec_map[canonical_name.lower()] = canonical_name # bare canonical name
arg_spec_map[f"-{canonical_name}".lower()] = canonical_name # single dash try:
arg_spec_map[f"--{canonical_name}".lower()] = canonical_name # double dash for flag in spec.to_flags():
arg_spec_map[str(flag).lower()] = canonical_name
except Exception:
arg_spec_map[f"-{canonical_name}".lower()] = canonical_name
arg_spec_map[f"--{canonical_name}".lower()] = canonical_name
# Parse arguments # Parse arguments
i = 0 i = 0
@@ -3143,16 +3150,6 @@ def register_url_with_local_library(
""" """
# Folder store removed; local library URL registration is disabled. # Folder store removed; local library URL registration is disabled.
return False return False
try:
# Provider-specific implementation lives with the provider code.
from Provider.tidal_manifest import resolve_tidal_manifest_path
except Exception: # pragma: no cover
def resolve_tidal_manifest_path(item: Any) -> Optional[str]:
_ = item
return None
def check_url_exists_in_storage( def check_url_exists_in_storage(
urls: Sequence[str], urls: Sequence[str],
storage: Any, storage: Any,
+79 -62
View File
@@ -176,14 +176,14 @@ class Add_File(Cmdlet):
super().__init__( super().__init__(
name="add-file", name="add-file",
summary= summary=
"Ingest a local media file to a store backend, file provider, or local directory.", "Ingest a local media file to a store backend, upload plugin, or local directory.",
usage= usage=
"add-file (-path <filepath> | <piped>) (-storage <location> | -provider <fileprovider>) [-delete]", "add-file (-path <filepath> | <piped>) (-storage <location> | -plugin <upload-plugin>) [-delete]",
arg=[ arg=[
SharedArgs.PATH, SharedArgs.PATH,
SharedArgs.STORE, SharedArgs.STORE,
SharedArgs.URL, SharedArgs.URL,
SharedArgs.PROVIDER, SharedArgs.PLUGIN,
CmdletArg( CmdletArg(
name="delete", name="delete",
type="flag", type="flag",
@@ -198,7 +198,7 @@ class Add_File(Cmdlet):
" hydrus: Upload to Hydrus database with metadata tagging", " hydrus: Upload to Hydrus database with metadata tagging",
" local: Copy file to local directory", " local: Copy file to local directory",
" <path>: Copy file to specified directory", " <path>: Copy file to specified directory",
"- File provider options (use -provider):", "- Upload plugin options (use -plugin):",
" 0x0: Upload to 0x0.st for temporary hosting", " 0x0: Upload to 0x0.st for temporary hosting",
" file.io: Upload to file.io for temporary hosting", " file.io: Upload to file.io for temporary hosting",
" internetarchive: Upload to archive.org (optional tag: ia:<identifier> to upload into an existing item)", " internetarchive: Upload to archive.org (optional tag: ia:<identifier> to upload into an existing item)",
@@ -224,13 +224,13 @@ class Add_File(Cmdlet):
path_arg = parsed.get("path") path_arg = parsed.get("path")
location = parsed.get("store") location = parsed.get("store")
source_url_arg = parsed.get("url") source_url_arg = parsed.get("url")
provider_name = parsed.get("provider") plugin_name = parsed.get("plugin")
delete_after = parsed.get("delete", False) delete_after = parsed.get("delete", False)
# Convenience: when piping a file into add-file, allow `-path <existing dir>` # Convenience: when piping a file into add-file, allow `-path <existing dir>`
# to act as the destination export directory. # to act as the destination export directory.
# Example: screen-shot "https://..." | add-file -path "C:\Users\Admin\Desktop" # Example: screen-shot "https://..." | add-file -path "C:\Users\Admin\Desktop"
if path_arg and not location and not provider_name: if path_arg and not location and not plugin_name:
try: try:
candidate_dir = Path(str(path_arg)) candidate_dir = Path(str(path_arg))
if candidate_dir.exists() and candidate_dir.is_dir(): if candidate_dir.exists() and candidate_dir.is_dir():
@@ -263,7 +263,7 @@ class Add_File(Cmdlet):
dir_scan_results: Optional[List[Dict[str, Any]]] = None dir_scan_results: Optional[List[Dict[str, Any]]] = None
explicit_path_list_results: Optional[List[Dict[str, Any]]] = None explicit_path_list_results: Optional[List[Dict[str, Any]]] = None
if path_arg and location and not provider_name: if path_arg and location and not plugin_name:
# Support comma-separated path lists: -path "file1,file2,file3" # Support comma-separated path lists: -path "file1,file2,file3"
# This is the mechanism used by @N expansion for directory tables. # This is the mechanism used by @N expansion for directory tables.
try: try:
@@ -403,7 +403,7 @@ class Add_File(Cmdlet):
("result_type", type(result).__name__), ("result_type", type(result).__name__),
("items", total_items), ("items", total_items),
("location", location), ("location", location),
("provider", provider_name), ("plugin", plugin_name),
("delete", delete_after), ("delete", delete_after),
], ],
border_style="cyan", border_style="cyan",
@@ -599,8 +599,8 @@ class Add_File(Cmdlet):
export_destination=(Path(location) if location and not is_storage_backend_location else None), export_destination=(Path(location) if location and not is_storage_backend_location else None),
store_instance=storage_registry, store_instance=storage_registry,
) )
if not media_path and provider_name: if not media_path and plugin_name:
media_path, file_hash, temp_dir_to_cleanup = Add_File._download_provider_source( media_path, file_hash, temp_dir_to_cleanup = Add_File._download_piped_source(
pipe_obj, config, storage_registry pipe_obj, config, storage_registry
) )
if media_path: if media_path:
@@ -610,7 +610,7 @@ class Add_File(Cmdlet):
[ [
("path", media_path), ("path", media_path),
("hash", file_hash or "N/A"), ("hash", file_hash or "N/A"),
("provider", provider_name or "local"), ("plugin", plugin_name or "local"),
], ],
border_style="green", border_style="green",
) )
@@ -635,10 +635,10 @@ class Add_File(Cmdlet):
progress.step("hashing file") progress.step("hashing file")
progress.step("ingesting file") progress.step("ingesting file")
if provider_name: if plugin_name:
code = self._handle_provider_upload( code = self._handle_plugin_upload(
media_path, media_path,
provider_name, plugin_name,
pipe_obj, pipe_obj,
config, config,
delete_after_item delete_after_item
@@ -1365,7 +1365,7 @@ class Add_File(Cmdlet):
hash_hint = get_field(result, "hash") or get_field(result, "file_hash") or getattr(pipe_obj, "hash", None) hash_hint = get_field(result, "hash") or get_field(result, "file_hash") or getattr(pipe_obj, "hash", None)
return candidate, hash_hint, None return candidate, hash_hint, None
downloaded_path, hash_hint, tmp_dir = Add_File._maybe_download_provider_result( downloaded_path, hash_hint, tmp_dir = Add_File._maybe_download_plugin_result(
result, result,
pipe_obj, pipe_obj,
config, config,
@@ -1393,45 +1393,41 @@ class Add_File(Cmdlet):
return normalized return normalized
@staticmethod @staticmethod
def _maybe_download_provider_result( def _maybe_download_plugin_result(
result: Any, result: Any,
pipe_obj: models.PipeObject, pipe_obj: models.PipeObject,
config: Dict[str, Any], config: Dict[str, Any],
) -> Tuple[Optional[Path], Optional[str], Optional[Path]]: ) -> Tuple[Optional[Path], Optional[str], Optional[Path]]:
provider_key = None plugin_key = None
for source in ( for source in (
pipe_obj.provider, pipe_obj.provider,
get_field(result, "plugin"),
get_field(result, "provider"), get_field(result, "provider"),
get_field(result, "table"), get_field(result, "table"),
): ):
candidate = Add_File._normalize_provider_key(source) candidate = Add_File._normalize_provider_key(source)
if candidate: if candidate:
provider_key = candidate plugin_key = candidate
break break
if not provider_key: if not plugin_key:
return None, None, None return None, None, None
provider = get_search_provider(provider_key, config) from ProviderCore.registry import get_search_plugin
if provider is None:
plugin = get_search_plugin(plugin_key, config)
if plugin is None:
return None, None, None return None, None, None
# Check for specialized download helper (used by AllDebrid and potentially others) try:
handler = getattr(provider, "download_for_pipe_result", None) return plugin.resolve_pipe_result_download(result, pipe_obj)
if not callable(handler): except Exception as exc:
# Fallback: check class if it's a classmethod and instance didn't have it (unlikely but safe) debug(f"[add-file] Plugin '{plugin_key}' download helper failed: {exc}")
handler = getattr(type(provider), "download_for_pipe_result", None)
if callable(handler):
try:
return handler(result, pipe_obj, config)
except Exception as exc:
debug(f"[add-file] Provider '{provider_key}' download helper failed: {exc}")
return None, None, None return None, None, None
@staticmethod @staticmethod
def _download_provider_source( def _download_piped_source(
pipe_obj: models.PipeObject, pipe_obj: models.PipeObject,
config: Dict[str, Any], config: Dict[str, Any],
store_instance: Optional[Any], store_instance: Optional[Any],
@@ -2152,23 +2148,23 @@ class Add_File(Cmdlet):
return 0 return 0
@staticmethod @staticmethod
def _handle_provider_upload( def _handle_plugin_upload(
media_path: Path, media_path: Path,
provider_name: str, plugin_name: str,
pipe_obj: models.PipeObject, pipe_obj: models.PipeObject,
config: Dict[str, config: Dict[str,
Any], Any],
delete_after: bool, delete_after: bool,
) -> int: ) -> int:
"""Handle uploading to a file provider (e.g. 0x0).""" """Handle uploading via an upload plugin (e.g. 0x0)."""
from ProviderCore.registry import get_file_provider from ProviderCore.registry import get_upload_plugin
log(f"Uploading via {provider_name}: {media_path.name}", file=sys.stderr) log(f"Uploading via {plugin_name}: {media_path.name}", file=sys.stderr)
try: try:
file_provider = get_file_provider(provider_name, config) file_provider = get_upload_plugin(plugin_name, config)
if not file_provider: if not file_provider:
log(f"File provider '{provider_name}' not available", file=sys.stderr) log(f"Upload plugin '{plugin_name}' not available", file=sys.stderr)
return 1 return 1
hoster_url = file_provider.upload(str(media_path), pipe_obj=pipe_obj) hoster_url = file_provider.upload(str(media_path), pipe_obj=pipe_obj)
@@ -2183,8 +2179,8 @@ class Add_File(Cmdlet):
# Update PipeObject and emit # Update PipeObject and emit
extra_updates: Dict[str, extra_updates: Dict[str,
Any] = { Any] = {
"provider": provider_name, "plugin": plugin_name,
"provider_url": hoster_url, "plugin_url": hoster_url,
} }
if isinstance(pipe_obj.extra, dict): if isinstance(pipe_obj.extra, dict):
# Also track hoster URL as a url for downstream steps # Also track hoster URL as a url for downstream steps
@@ -2197,7 +2193,7 @@ class Add_File(Cmdlet):
Add_File._update_pipe_object_destination( Add_File._update_pipe_object_destination(
pipe_obj, pipe_obj,
hash_value=f_hash or "unknown", hash_value=f_hash or "unknown",
store=provider_name or "provider", store=plugin_name or "plugin",
path=file_path, path=file_path,
tag=pipe_obj.tag, tag=pipe_obj.tag,
title=pipe_obj.title or (media_path.name if media_path else None), title=pipe_obj.title or (media_path.name if media_path else None),
@@ -2445,9 +2441,6 @@ class Add_File(Cmdlet):
try: try:
adder = getattr(backend, "add_tag", None) adder = getattr(backend, "add_tag", None)
if callable(adder): if callable(adder):
debug(
f"[add-file] Applying {len(tags)} tag(s) post-upload to {backend_name}"
)
adder(resolved_hash, list(tags)) adder(resolved_hash, list(tags))
except Exception as exc: except Exception as exc:
log(f"[add-file] Post-upload tagging failed for {backend_name}: {exc}", file=sys.stderr) log(f"[add-file] Post-upload tagging failed for {backend_name}: {exc}", file=sys.stderr)
@@ -2479,48 +2472,72 @@ class Add_File(Cmdlet):
try: try:
setter = getattr(backend, "set_note", None) setter = getattr(backend, "set_note", None)
if callable(setter): if callable(setter):
debug(
f"[add-file] Writing sub note (len={len(str(sub_note))}) to {backend_name}:{resolved_hash}"
)
setter(resolved_hash, "sub", sub_note) setter(resolved_hash, "sub", sub_note)
except Exception as exc: except Exception as exc:
debug(f"[add-file] sub note write failed: {exc}") debug_panel(
"add-file note write failed",
[
("store", backend_name),
("hash", resolved_hash),
("note", "sub"),
("error", exc),
],
border_style="yellow",
)
lyric_note = Add_File._get_note_text(result, pipe_obj, "lyric") lyric_note = Add_File._get_note_text(result, pipe_obj, "lyric")
if lyric_note: if lyric_note:
try: try:
setter = getattr(backend, "set_note", None) setter = getattr(backend, "set_note", None)
if callable(setter): if callable(setter):
debug(
f"[add-file] Writing lyric note (len={len(str(lyric_note))}) to {backend_name}:{resolved_hash}"
)
setter(resolved_hash, "lyric", lyric_note) setter(resolved_hash, "lyric", lyric_note)
except Exception as exc: except Exception as exc:
debug(f"[add-file] lyric note write failed: {exc}") debug_panel(
"add-file note write failed",
[
("store", backend_name),
("hash", resolved_hash),
("note", "lyric"),
("error", exc),
],
border_style="yellow",
)
chapters_note = Add_File._get_note_text(result, pipe_obj, "chapters") chapters_note = Add_File._get_note_text(result, pipe_obj, "chapters")
if chapters_note: if chapters_note:
try: try:
setter = getattr(backend, "set_note", None) setter = getattr(backend, "set_note", None)
if callable(setter): if callable(setter):
debug(
f"[add-file] Writing chapters note (len={len(str(chapters_note))}) to {backend_name}:{resolved_hash}"
)
setter(resolved_hash, "chapters", chapters_note) setter(resolved_hash, "chapters", chapters_note)
except Exception as exc: except Exception as exc:
debug(f"[add-file] chapters note write failed: {exc}") debug_panel(
"add-file note write failed",
[
("store", backend_name),
("hash", resolved_hash),
("note", "chapters"),
("error", exc),
],
border_style="yellow",
)
caption_note = Add_File._get_note_text(result, pipe_obj, "caption") caption_note = Add_File._get_note_text(result, pipe_obj, "caption")
if caption_note: if caption_note:
try: try:
setter = getattr(backend, "set_note", None) setter = getattr(backend, "set_note", None)
if callable(setter): if callable(setter):
debug(
f"[add-file] Writing caption note (len={len(str(caption_note))}) to {backend_name}:{resolved_hash}"
)
setter(resolved_hash, "caption", caption_note) setter(resolved_hash, "caption", caption_note)
except Exception as exc: except Exception as exc:
debug(f"[add-file] caption note write failed: {exc}") debug_panel(
"add-file note write failed",
[
("store", backend_name),
("hash", resolved_hash),
("note", "caption"),
("error", exc),
],
border_style="yellow",
)
meta: Dict[str, meta: Dict[str,
Any] = {} Any] = {}
+205 -2023
View File
File diff suppressed because it is too large Load Diff
+141 -869
View File
File diff suppressed because it is too large Load Diff
+13 -47
View File
@@ -154,47 +154,7 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
if urls_to_download and len(urls_to_download) >= 2: if urls_to_download and len(urls_to_download) >= 2:
try: try:
# Compute a batch hint (audio vs video + single-format id) once. from ProviderCore.registry import get_plugin_for_url
mode_hint: Optional[str] = None
forced_format: Optional[str] = None
try:
from tool.ytdlp import YtDlpTool, list_formats
sample_url = urls_to_download[0]
cookiefile = None
try:
cookie_path = YtDlpTool(config).resolve_cookiefile()
if cookie_path is not None and cookie_path.is_file():
cookiefile = str(cookie_path)
except Exception:
cookiefile = None
fmts = list_formats(
sample_url,
no_playlist=False,
playlist_items=None,
cookiefile=cookiefile
)
if isinstance(fmts, list) and fmts:
has_video = False
for f in fmts:
if not isinstance(f, dict):
continue
vcodec = str(f.get("vcodec", "none") or "none").strip().lower()
if vcodec and vcodec != "none":
has_video = True
break
mode_hint = "video" if has_video else "audio"
if len(fmts) == 1 and isinstance(fmts[0], dict):
fid = str(fmts[0].get("format_id") or "").strip()
if fid:
forced_format = fid
except Exception:
mode_hint = None
forced_format = None
from cmdlet.download_file import Download_File
expanded: List[Dict[str, Any]] = [] expanded: List[Dict[str, Any]] = []
downloaded_any = False downloaded_any = False
@@ -207,12 +167,18 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
expanded.append(it) expanded.append(it)
continue continue
downloaded = Download_File.download_streaming_url_as_pipe_objects( downloaded = []
u, try:
config, plugin = get_plugin_for_url(u, config)
mode_hint=mode_hint, except Exception:
ytdl_format_hint=forced_format, plugin = None
) if plugin is not None and hasattr(plugin, "download_url_as_pipe_objects"):
try:
downloaded = plugin.download_url_as_pipe_objects(u)
except TypeError:
downloaded = plugin.download_url_as_pipe_objects(u, output_dir=None)
except Exception:
downloaded = []
if downloaded: if downloaded:
expanded.extend(downloaded) expanded.extend(downloaded)
downloaded_any = True downloaded_any = True
+15 -15
View File
@@ -7,7 +7,7 @@ from . import _shared as sh
from SYS.logger import log from SYS.logger import log
from SYS import pipeline as ctx from SYS import pipeline as ctx
from SYS.result_table_adapters import get_provider from SYS.result_table_adapters import get_plugin
from SYS.result_table_renderers import RichRenderer from SYS.result_table_renderers import RichRenderer
Cmdlet = sh.Cmdlet Cmdlet = sh.Cmdlet
@@ -16,19 +16,19 @@ parse_cmdlet_args = sh.parse_cmdlet_args
CMDLET = Cmdlet( CMDLET = Cmdlet(
name="provider-table", name="plugin-table",
summary="Render a provider's result set and optionally run a follow-up cmdlet using the selected row.", summary="Render a plugin's result set and optionally run a follow-up cmdlet using the selected row.",
usage="provider-table -provider <name> [-sample] [-select <n>] [-run-cmd <name>]", usage="plugin-table -plugin <name> [-sample] [-select <n>] [-run-cmd <name>]",
arg=[ arg=[
CmdletArg("provider", type="string", description="Provider name to render (default: example)"), CmdletArg("plugin", type="string", description="Plugin name to render (default: example)"),
CmdletArg("sample", type="flag", description="Use provider sample/demo items when available."), CmdletArg("sample", type="flag", description="Use plugin sample/demo items when available."),
CmdletArg("select", type="int", description="1-based row index to select and use for follow-up command."), CmdletArg("select", type="int", description="1-based row index to select and use for follow-up command."),
CmdletArg("run-cmd", type="string", description="Cmdlet to invoke with the selected row's selector args."), CmdletArg("run-cmd", type="string", description="Cmdlet to invoke with the selected row's selector args."),
], ],
detail=[ detail=[
"Use a registered provider to build a table and optionally run another cmdlet with selection args.", "Use a registered plugin to build a table and optionally run another cmdlet with selection args.",
"Emits pipeline-friendly dicts enriched with `_selection_args` so you can use @N syntax to select and chain.", "Emits pipeline-friendly dicts enriched with `_selection_args` so you can use @N syntax to select and chain.",
"Example: provider-table -provider example -sample | @1 | add-file -store my_store", "Example: plugin-table -plugin example -sample | @1 | add-file -store my_store",
], ],
) )
@@ -36,15 +36,15 @@ CMDLET = Cmdlet(
def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
parsed = parse_cmdlet_args(args, CMDLET) parsed = parse_cmdlet_args(args, CMDLET)
provider_name = parsed.get("provider") or "example" plugin_name = parsed.get("plugin") or "example"
use_sample = bool(parsed.get("sample", False)) use_sample = bool(parsed.get("sample", False))
run_cmd = parsed.get("run-cmd") run_cmd = parsed.get("run-cmd")
select_raw = parsed.get("select") select_raw = parsed.get("select")
try: try:
provider = get_provider(provider_name) provider = get_plugin(plugin_name)
except Exception: except Exception:
log(f"Unknown provider: {provider_name}", file=sys.stderr) log(f"Unknown plugin: {plugin_name}", file=sys.stderr)
return 1 return 1
# Obtain items to feed to the adapter # Obtain items to feed to the adapter
@@ -55,23 +55,23 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
mod = __import__(provider.adapter.__module__, fromlist=["*"]) mod = __import__(provider.adapter.__module__, fromlist=["*"])
items = getattr(mod, "SAMPLE_ITEMS", None) items = getattr(mod, "SAMPLE_ITEMS", None)
if items is None: if items is None:
log("Provider does not expose SAMPLE_ITEMS; no sample available", file=sys.stderr) log("Plugin does not expose SAMPLE_ITEMS; no sample available", file=sys.stderr)
return 1 return 1
except Exception: except Exception:
log("Failed to load provider sample", file=sys.stderr) log("Failed to load plugin sample", file=sys.stderr)
return 1 return 1
else: else:
# Require input for non-sample runs # Require input for non-sample runs
inputs = list(result) if isinstance(result, Iterable) else [] inputs = list(result) if isinstance(result, Iterable) else []
if not inputs: if not inputs:
log("No input provided. Use -sample for demo or pipe provider items in.", file=sys.stderr) log("No input provided. Use -sample for demo or pipe plugin items in.", file=sys.stderr)
return 1 return 1
items = inputs items = inputs
try: try:
table = provider.build_table(items) table = provider.build_table(items)
except Exception as exc: except Exception as exc:
log(f"Provider '{provider.name}' failed: {exc}", file=sys.stderr) log(f"Plugin '{provider.name}' failed: {exc}", file=sys.stderr)
return 1 return 1
# Emit rows for downstream pipeline consumption (pipable behavior). # Emit rows for downstream pipeline consumption (pipable behavior).
+31 -85
View File
@@ -15,7 +15,7 @@ from urllib.parse import urlparse, parse_qs, unquote, urljoin
from SYS.logger import log, debug from SYS.logger import log, debug
from SYS.payload_builders import build_file_result_payload, normalize_file_extension from SYS.payload_builders import build_file_result_payload, normalize_file_extension
from ProviderCore.registry import get_search_provider, list_search_providers from ProviderCore.registry import get_search_plugin, list_search_plugins
from SYS.rich_display import ( from SYS.rich_display import (
show_provider_config_panel, show_provider_config_panel,
show_store_config_panel, show_store_config_panel,
@@ -169,8 +169,8 @@ class search_file(Cmdlet):
def __init__(self) -> None: def __init__(self) -> None:
super().__init__( super().__init__(
name="search-file", name="search-file",
summary="Search storage backends (Hydrus) or external providers (via -provider).", summary="Search storage backends (Hydrus) or external plugins (via -plugin).",
usage="search-file [-query <query>] [-store BACKEND] [-limit N] [-provider NAME]", usage="search-file [-query <query>] [-store BACKEND] [-limit N] [-plugin NAME]",
arg=[ arg=[
CmdletArg( CmdletArg(
"limit", "limit",
@@ -179,11 +179,7 @@ class search_file(Cmdlet):
), ),
SharedArgs.STORE, SharedArgs.STORE,
SharedArgs.QUERY, SharedArgs.QUERY,
CmdletArg( SharedArgs.PLUGIN,
"provider",
type="string",
description="External provider name (e.g., tidal, youtube, soulseek, etc)",
),
CmdletArg( CmdletArg(
"open", "open",
type="integer", type="integer",
@@ -209,10 +205,10 @@ class search_file(Cmdlet):
"search-file 'example.com/path' -query 'ext:pdf' # Web: site:example.com filetype:pdf", "search-file 'example.com/path' -query 'ext:pdf' # Web: site:example.com filetype:pdf",
"search-file -query 'site:example.com filetype:epub history' # Web: site-scoped search", "search-file -query 'site:example.com filetype:epub history' # Web: site-scoped search",
"", "",
"Provider search (-provider):", "Plugin search (-plugin):",
"search-file -provider youtube 'tutorial' # Search YouTube provider", "search-file -plugin youtube 'tutorial' # Search YouTube plugin",
"search-file -provider alldebrid '*' # List AllDebrid magnets", "search-file -plugin alldebrid '*' # List AllDebrid magnets",
"search-file -provider alldebrid -open 123 '*' # Show files for a magnet", "search-file -plugin alldebrid -open 123 '*' # Show files for a magnet",
], ],
exec=self.run, exec=self.run,
) )
@@ -1451,10 +1447,10 @@ class search_file(Cmdlet):
self._set_storage_display_columns(payload) self._set_storage_display_columns(payload)
return payload return payload
def _run_provider_search( def _run_plugin_search(
self, self,
*, *,
provider_name: str, plugin_name: str,
query: str, query: str,
limit: int, limit: int,
limit_set: bool, limit_set: bool,
@@ -1463,9 +1459,9 @@ class search_file(Cmdlet):
refresh_mode: bool, refresh_mode: bool,
config: Dict[str, Any], config: Dict[str, Any],
) -> int: ) -> int:
"""Execute external provider search.""" """Execute external plugin search."""
if not provider_name or not query: if not plugin_name or not query:
from SYS import pipeline as ctx_mod from SYS import pipeline as ctx_mod
progress = None progress = None
if hasattr(ctx_mod, "get_pipeline_state"): if hasattr(ctx_mod, "get_pipeline_state"):
@@ -1476,10 +1472,10 @@ class search_file(Cmdlet):
except Exception: except Exception:
pass pass
log("Error: search-file -provider requires both provider and query", file=sys.stderr) log("Error: search-file -plugin requires both plugin and query", file=sys.stderr)
log(f"Usage: {self.usage}", file=sys.stderr) log(f"Usage: {self.usage}", file=sys.stderr)
providers_map = list_search_providers(config) providers_map = list_search_plugins(config)
available = [n for n, a in providers_map.items() if a] available = [n for n, a in providers_map.items() if a]
unconfigured = [n for n, a in providers_map.items() if not a] unconfigured = [n for n, a in providers_map.items() if not a]
@@ -1500,7 +1496,7 @@ class search_file(Cmdlet):
if hasattr(ctx_mod, "get_pipeline_state"): if hasattr(ctx_mod, "get_pipeline_state"):
progress = ctx_mod.get_pipeline_state().live_progress progress = ctx_mod.get_pipeline_state().live_progress
provider = get_search_provider(provider_name, config) provider = get_search_plugin(plugin_name, config)
if not provider: if not provider:
if progress: if progress:
try: try:
@@ -1508,9 +1504,9 @@ class search_file(Cmdlet):
except Exception: except Exception:
pass pass
show_provider_config_panel([provider_name]) show_provider_config_panel([plugin_name])
providers_map = list_search_providers(config) providers_map = list_search_plugins(config)
available = [n for n, a in providers_map.items() if a] available = [n for n, a in providers_map.items() if a]
if available: if available:
show_available_providers_panel(available) show_available_providers_panel(available)
@@ -1522,7 +1518,7 @@ class search_file(Cmdlet):
worker_id, worker_id,
"search-file", "search-file",
title=f"Search: {query}", title=f"Search: {query}",
description=f"Provider: {provider_name}, Query: {query}", description=f"Plugin: {plugin_name}, Query: {query}",
) )
except Exception: except Exception:
pass pass
@@ -1532,7 +1528,7 @@ class search_file(Cmdlet):
from SYS.result_table import Table from SYS.result_table import Table
provider_text = str(provider_name or "").strip() provider_text = str(plugin_name or "").strip()
provider_lower = provider_text.lower() provider_lower = provider_text.lower()
# Dynamic query/filter extraction via provider # Dynamic query/filter extraction via provider
@@ -1564,9 +1560,9 @@ class search_file(Cmdlet):
source_cmd, source_args = provider.get_source_command(args_list) source_cmd, source_args = provider.get_source_command(args_list)
table.set_source_command(source_cmd, source_args) table.set_source_command(source_cmd, source_args)
debug(f"[search-file] Calling {provider_name}.search(filters={search_filters})") debug(f"[search-file] Calling {plugin_name}.search(filters={search_filters})")
results = provider.search(query, limit=limit, filters=search_filters or None) results = provider.search(query, limit=limit, filters=search_filters or None)
debug(f"[search-file] {provider_name} -> {len(results or [])} result(s)") debug(f"[search-file] {plugin_name} -> {len(results or [])} result(s)")
# Allow providers to apply provider-specific UX transforms (e.g. auto-expansion) # Allow providers to apply provider-specific UX transforms (e.g. auto-expansion)
try: try:
@@ -1615,7 +1611,7 @@ class search_file(Cmdlet):
# Ensure provider source is present so downstream cmdlets (select) can resolve provider # Ensure provider source is present so downstream cmdlets (select) can resolve provider
if "source" not in item_dict: if "source" not in item_dict:
item_dict["source"] = provider_name item_dict["source"] = plugin_name
row_index = len(table.rows) row_index = len(table.rows)
table.add_result(search_result) table.add_result(search_result)
@@ -1636,7 +1632,7 @@ class search_file(Cmdlet):
return 0 return 0
except Exception as exc: except Exception as exc:
log(f"Error searching provider '{provider_name}': {exc}", file=sys.stderr) log(f"Error searching plugin '{plugin_name}': {exc}", file=sys.stderr)
import traceback import traceback
debug(traceback.format_exc()) debug(traceback.format_exc())
@@ -1728,9 +1724,9 @@ class search_file(Cmdlet):
f.lower() f.lower()
for f in (flag_registry.get("limit") or {"-limit", "--limit"}) for f in (flag_registry.get("limit") or {"-limit", "--limit"})
} }
provider_flags = { plugin_flags = {
f.lower() f.lower()
for f in (flag_registry.get("provider") or {"-provider", "--provider"}) for f in (flag_registry.get("plugin") or {"-plugin", "--plugin"})
} }
open_flags = { open_flags = {
f.lower() f.lower()
@@ -1740,7 +1736,7 @@ class search_file(Cmdlet):
# Parse arguments # Parse arguments
query = "" query = ""
storage_backend: Optional[str] = None storage_backend: Optional[str] = None
provider_name: Optional[str] = None plugin_name: Optional[str] = None
open_id: Optional[int] = None open_id: Optional[int] = None
limit = 100 limit = 100
limit_set = False limit_set = False
@@ -1756,8 +1752,8 @@ class search_file(Cmdlet):
query = f"{query} {chunk}".strip() if query else chunk query = f"{query} {chunk}".strip() if query else chunk
i += 2 i += 2
continue continue
if low in provider_flags and i + 1 < len(args_list): if low in plugin_flags and i + 1 < len(args_list):
provider_name = args_list[i + 1] plugin_name = args_list[i + 1]
i += 2 i += 2
continue continue
if low in open_flags and i + 1 < len(args_list): if low in open_flags and i + 1 < len(args_list):
@@ -1790,9 +1786,9 @@ class search_file(Cmdlet):
query = query.strip() query = query.strip()
if provider_name: if plugin_name:
return self._run_provider_search( return self._run_plugin_search(
provider_name=provider_name, plugin_name=plugin_name,
query=query, query=query,
limit=limit, limit=limit,
limit_set=limit_set, limit_set=limit_set,
@@ -1814,56 +1810,6 @@ class search_file(Cmdlet):
if store_filter and not storage_backend: if store_filter and not storage_backend:
storage_backend = store_filter storage_backend = store_filter
# If the user accidentally used `-store <provider>` or `store:<provider>`,
# prefer to treat it as a provider search (providers like 'alldebrid' are not store backends).
try:
from Store.registry import list_configured_backend_names
providers_map = list_search_providers(config)
configured = list_configured_backend_names(config or {})
if storage_backend:
matched = None
storage_hint = self._normalize_lookup_target(storage_backend)
if storage_hint:
for p in (providers_map or {}):
if self._normalize_lookup_target(p) == storage_hint:
matched = p
break
if matched and str(storage_backend) not in configured:
log(f"Note: Treating '-store {storage_backend}' as provider search for '{matched}'", file=sys.stderr)
return self._run_provider_search(
provider_name=matched,
query=query,
limit=limit,
limit_set=limit_set,
open_id=open_id,
args_list=args_list,
refresh_mode=refresh_mode,
config=config,
)
elif store_filter:
matched = None
store_hint = self._normalize_lookup_target(store_filter)
if store_hint:
for p in (providers_map or {}):
if self._normalize_lookup_target(p) == store_hint:
matched = p
break
if matched and str(store_filter) not in configured:
log(f"Note: Treating 'store:{store_filter}' as provider search for '{matched}'", file=sys.stderr)
return self._run_provider_search(
provider_name=matched,
query=query,
limit=limit,
limit_set=limit_set,
open_id=open_id,
args_list=args_list,
refresh_mode=refresh_mode,
config=config,
)
except Exception:
# Be conservative: if provider detection fails, fall back to store behaviour
pass
hash_query = parse_hash_query(query) hash_query = parse_hash_query(query)
web_plan = self._build_web_search_plan( web_plan = self._build_web_search_plan(
+57 -25
View File
@@ -74,34 +74,9 @@ def ping_url(url: str, timeout: float = 3.0) -> tuple[bool, str]:
def provider_display_name(key: str) -> str: def provider_display_name(key: str) -> str:
label = (key or "").strip() label = (key or "").strip()
lower = label.lower()
if lower == "openlibrary":
return "OpenLibrary"
if lower == "alldebrid":
return "AllDebrid"
if lower == "youtube":
return "YouTube"
return label[:1].upper() + label[1:] if label else "Provider" return label[:1].upper() + label[1:] if label else "Provider"
def default_provider_ping_targets(provider_key: str) -> list[str]:
provider = (provider_key or "").strip().lower()
if provider == "openlibrary":
return ["https://openlibrary.org"]
if provider == "youtube":
return ["https://www.youtube.com"]
if provider == "bandcamp":
return ["https://bandcamp.com"]
if provider == "libgen":
try:
from Provider.libgen import MIRRORS
return [str(url).rstrip("/") + "/json.php" for url in (MIRRORS or []) if str(url).strip()]
except ImportError:
return []
return []
def ping_first(urls: list[str]) -> tuple[bool, str]: def ping_first(urls: list[str]) -> tuple[bool, str]:
for url in urls: for url in urls:
ok, detail = ping_url(url) ok, detail = ping_url(url)
@@ -110,3 +85,60 @@ def ping_first(urls: list[str]) -> tuple[bool, str]:
if urls: if urls:
return ping_url(urls[0]) return ping_url(urls[0])
return False, "No ping target" return False, "No ping target"
def collect_plugin_startup_checks(config: dict) -> list[dict[str, Any]]:
provider_cfg = config.get("provider") if isinstance(config, dict) else None
if not isinstance(provider_cfg, dict) or not provider_cfg:
return []
try:
from ProviderCore.registry import get_plugin_class
except Exception:
return []
checks: list[dict[str, Any]] = []
for plugin_name in provider_cfg.keys():
plugin_key = str(plugin_name or "").strip().lower()
if not plugin_key:
continue
plugin_class = None
try:
plugin_class = get_plugin_class(plugin_key)
except Exception:
plugin_class = None
if plugin_class is None:
checks.append(
{
"status": "UNKNOWN",
"name": provider_display_name(plugin_key),
"plugin": plugin_key,
"detail": "Not registered",
}
)
continue
try:
plugin = plugin_class(config)
summary = plugin.status_summary()
except Exception as exc:
summary = {
"status": "DISABLED",
"name": provider_display_name(plugin_key),
"plugin": plugin_key,
"detail": str(exc),
}
checks.append(
{
"status": str(summary.get("status") or "UNKNOWN"),
"name": str(summary.get("name") or provider_display_name(plugin_key)),
"plugin": str(summary.get("plugin") or plugin_key),
"detail": str(summary.get("detail") or ""),
"files": summary.get("files"),
}
)
return checks
+49 -74
View File
@@ -15,6 +15,7 @@ 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 import pipeline as ctx
from ProviderCore.registry import get_plugin, get_plugin_for_url
from cmdnat._parsing import ( from cmdnat._parsing import (
extract_arg_value, extract_arg_value,
extract_piped_value as _extract_piped_value, extract_piped_value as _extract_piped_value,
@@ -29,6 +30,29 @@ _MATRIX_MENU_STATE_KEY = "matrix_menu_state"
_MATRIX_SELECTED_SETTING_KEY_KEY = "matrix_selected_setting_key" _MATRIX_SELECTED_SETTING_KEY_KEY = "matrix_selected_setting_key"
def _get_matrix_provider(config: Dict[str, Any]) -> Any:
provider = get_plugin("matrix", config)
if provider is None:
raise RuntimeError("Matrix plugin is not registered")
return provider
def _resolve_plugin_url(url: str, config: Dict[str, Any]) -> str:
target = str(url or "").strip()
if not target:
return target
provider = get_plugin_for_url(target, config)
if provider is None:
return target
try:
resolved = provider.resolve_url(target)
except Exception:
return target
return str(resolved or target)
def _extract_set_value_arg(args: Sequence[str]) -> Optional[str]: def _extract_set_value_arg(args: Sequence[str]) -> Optional[str]:
"""Extract the value from -set-value flag.""" """Extract the value from -set-value flag."""
return extract_arg_value(args, flags={"-set-value"}) return extract_arg_value(args, flags={"-set-value"})
@@ -212,35 +236,11 @@ def _resolve_room_identifier(value: str, config: Dict[str, Any]) -> Optional[str
conf_ids = _parse_config_room_filter_ids(config) conf_ids = _parse_config_room_filter_ids(config)
if conf_ids: if conf_ids:
# Attempt to fetch names for the configured IDs # Attempt to fetch names for the configured IDs
try:
from Provider.matrix import Matrix
# Avoid __init__ network failures by requiring homeserver+token to exist
block = config.get("provider", {}).get("matrix", {})
if block and block.get("homeserver") and block.get("access_token"):
try:
m = Matrix(config)
rooms = m.list_rooms(room_ids=conf_ids)
for room in rooms or []:
name = str(room.get("name") or "").strip()
rid = str(room.get("room_id") or "").strip()
if name and name.lower() == cand.lower():
return rid
if name and cand.lower() in name.lower():
return rid
except Exception:
# Best-effort; fallback below
pass
except Exception:
pass
# Last resort: attempt to ask the server for matching rooms (if possible)
try:
from Provider.matrix import Matrix
block = config.get("provider", {}).get("matrix", {}) block = config.get("provider", {}).get("matrix", {})
if block and block.get("homeserver") and block.get("access_token"): if block and block.get("homeserver") and block.get("access_token"):
try: try:
m = Matrix(config) m = _get_matrix_provider(config)
rooms = m.list_rooms() rooms = m.list_rooms(room_ids=conf_ids)
for room in rooms or []: for room in rooms or []:
name = str(room.get("name") or "").strip() name = str(room.get("name") or "").strip()
rid = str(room.get("room_id") or "").strip() rid = str(room.get("room_id") or "").strip()
@@ -250,8 +250,22 @@ def _resolve_room_identifier(value: str, config: Dict[str, Any]) -> Optional[str
return rid return rid
except Exception: except Exception:
pass pass
except Exception:
pass # Last resort: attempt to ask the server for matching rooms (if possible)
block = config.get("provider", {}).get("matrix", {})
if block and block.get("homeserver") and block.get("access_token"):
try:
m = _get_matrix_provider(config)
rooms = m.list_rooms()
for room in rooms or []:
name = str(room.get("name") or "").strip()
rid = str(room.get("room_id") or "").strip()
if name and name.lower() == cand.lower():
return rid
if name and cand.lower() in name.lower():
return rid
except Exception:
pass
return None return None
except Exception: except Exception:
@@ -270,10 +284,8 @@ def _send_pending_to_rooms(config: Dict[str, Any], room_ids: List[str], args: Se
log("No pending items to upload (use: @N | .matrix)", file=sys.stderr) log("No pending items to upload (use: @N | .matrix)", file=sys.stderr)
return 1 return 1
from Provider.matrix import Matrix
try: try:
provider = Matrix(config) provider = _get_matrix_provider(config)
except Exception as exc: except Exception as exc:
log(f"Matrix not available: {exc}", file=sys.stderr) log(f"Matrix not available: {exc}", file=sys.stderr)
return 1 return 1
@@ -585,35 +597,6 @@ def _maybe_download_hydrus_file(item: Any,
return None return None
def _maybe_unlock_alldebrid_url(url: str, config: Dict[str, Any]) -> str:
try:
parsed = urlparse(url)
host = (parsed.netloc or "").lower()
if host != "alldebrid.com":
return url
if not (parsed.path or "").startswith("/f/"):
return url
try:
from Provider.alldebrid import _get_debrid_api_key # type: ignore
api_key = _get_debrid_api_key(config or {})
except Exception:
api_key = None
if not api_key:
return url
from API.alldebrid import AllDebridClient
client = AllDebridClient(str(api_key))
unlocked = client.unlock_link(url)
if isinstance(unlocked, str) and unlocked.strip():
return unlocked.strip()
except Exception:
pass
return url
def _resolve_upload_path(item: Any, config: Dict[str, Any]) -> Optional[str]: def _resolve_upload_path(item: Any, config: Dict[str, Any]) -> Optional[str]:
"""Resolve a usable local file path for uploading. """Resolve a usable local file path for uploading.
@@ -645,7 +628,7 @@ def _resolve_upload_path(item: Any, config: Dict[str, Any]) -> Optional[str]:
return None return None
# Best-effort: unlock AllDebrid file links (they require auth and aren't directly downloadable). # Best-effort: unlock AllDebrid file links (they require auth and aren't directly downloadable).
url = _maybe_unlock_alldebrid_url(url, config) url = _resolve_plugin_url(url, config)
try: try:
from API.HTTP import _download_direct_file from API.HTTP import _download_direct_file
@@ -851,10 +834,8 @@ def _handle_settings_edit(result: Any, args: Sequence[str], config: Dict[str, An
def _handle_settings_test(config: Dict[str, Any]) -> int: def _handle_settings_test(config: Dict[str, Any]) -> int:
"""Test Matrix credentials and prompt for default rooms upon success.""" """Test Matrix credentials and prompt for default rooms upon success."""
from Provider.matrix import Matrix
try: try:
provider = Matrix(config) provider = _get_matrix_provider(config)
except Exception as exc: except Exception as exc:
log(f"Matrix test failed: {exc}", file=sys.stderr) log(f"Matrix test failed: {exc}", file=sys.stderr)
return 1 return 1
@@ -863,13 +844,11 @@ def _handle_settings_test(config: Dict[str, Any]) -> int:
return _show_default_room_picker(config, provider=provider) return _show_default_room_picker(config, provider=provider)
def _show_default_room_picker(config: Dict[str, Any], *, provider: Optional["Matrix"] = None) -> int: def _show_default_room_picker(config: Dict[str, Any], *, provider: Optional[Any] = None) -> int:
"""Display joined rooms so the user can select defaults for sharing.""" """Display joined rooms so the user can select defaults for sharing."""
from Provider.matrix import Matrix
try: try:
if provider is None: if provider is None:
provider = Matrix(config) provider = _get_matrix_provider(config)
except Exception as exc: except Exception as exc:
log(f"Matrix not available: {exc}", file=sys.stderr) log(f"Matrix not available: {exc}", file=sys.stderr)
return 1 return 1
@@ -977,10 +956,8 @@ def _handle_settings_rooms(result: Any, args: Sequence[str], config: Dict[str, A
def _show_rooms_table(config: Dict[str, Any]) -> int: def _show_rooms_table(config: Dict[str, Any]) -> int:
"""Display rooms (refactored original behavior).""" """Display rooms (refactored original behavior)."""
from Provider.matrix import Matrix
try: try:
provider = Matrix(config) provider = _get_matrix_provider(config)
except Exception as exc: except Exception as exc:
log(f"Matrix not available: {exc}", file=sys.stderr) log(f"Matrix not available: {exc}", file=sys.stderr)
return 1 return 1
@@ -1121,10 +1098,8 @@ def _run(result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
log("No pending items to upload (use: @N | .matrix)", file=sys.stderr) log("No pending items to upload (use: @N | .matrix)", file=sys.stderr)
return 1 return 1
from Provider.matrix import Matrix
try: try:
provider = Matrix(config) provider = _get_matrix_provider(config)
except Exception as exc: except Exception as exc:
log(f"Matrix not available: {exc}", file=sys.stderr) log(f"Matrix not available: {exc}", file=sys.stderr)
return 1 return 1
+75 -67
View File
@@ -10,17 +10,15 @@ 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 Provider.tidal_manifest import resolve_tidal_manifest_path from ProviderCore.registry import get_plugin_for_url
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 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
from SYS.config import get_hydrus_access_key, get_hydrus_url from SYS.config import get_hydrus_access_key, get_hydrus_url, resolve_cookies_path
_ALLDEBRID_UNLOCK_CACHE: Dict[str,
str] = {}
_NOTES_PREFETCH_INFLIGHT: set[str] = set() _NOTES_PREFETCH_INFLIGHT: set[str] = set()
_NOTES_PREFETCH_LOCK = threading.Lock() _NOTES_PREFETCH_LOCK = threading.Lock()
_PLAYLIST_STORE_CACHE: Optional[Dict[str, Any]] = None _PLAYLIST_STORE_CACHE: Optional[Dict[str, Any]] = None
@@ -478,73 +476,85 @@ def _try_enable_mpv_file_logging(mpv_log_path: str, *, attempts: int = 3) -> boo
return bool(ok) return bool(ok)
def _get_alldebrid_api_key(config: Optional[Dict[str, Any]]) -> Optional[str]: def _iter_plugin_targets(item: Any) -> List[str]:
values: List[str] = []
seen: set[str] = set()
def _add(candidate: Any) -> None:
text = str(candidate or "").strip()
if not text or text in seen:
return
seen.add(text)
values.append(text)
try: try:
if not isinstance(config, dict): if isinstance(item, dict):
return None _add(item.get("path"))
provider_cfg = config.get("provider") _add(item.get("url"))
if not isinstance(provider_cfg, dict): _add(item.get("source_url"))
return None _add(item.get("target"))
ad_cfg = provider_cfg.get("alldebrid") metadata = item.get("full_metadata") or item.get("metadata")
if not isinstance(ad_cfg, dict): else:
return None _add(getattr(item, "path", None))
key = ad_cfg.get("api_key") _add(getattr(item, "url", None))
if not isinstance(key, str): _add(getattr(item, "source_url", None))
return None _add(getattr(item, "target", None))
key = key.strip() metadata = getattr(item, "full_metadata", None) or getattr(item, "metadata", None)
return key or None if isinstance(metadata, dict):
_add(metadata.get("url"))
_add(metadata.get("webpage_url"))
_add(metadata.get("source_url"))
extra = item.get("extra") if isinstance(item, dict) else getattr(item, "extra", None)
if isinstance(extra, dict):
_add(extra.get("url"))
_add(extra.get("source_url"))
except Exception: except Exception:
return None return values
return values
def _is_alldebrid_protected_url(url: str) -> bool: def _resolve_plugin_url(url: str, config: Optional[Dict[str, Any]]) -> str:
target = str(url or "").strip()
if not target:
return target
try: try:
if not isinstance(url, str): plugin = get_plugin_for_url(target, config or {})
return False
u = url.strip()
if not u.startswith(("http://", "https://")):
return False
p = urlparse(u)
host = (p.netloc or "").lower()
path = p.path or ""
# AllDebrid file page links (require auth; not directly streamable by mpv)
return host == "alldebrid.com" and path.startswith("/f/")
except Exception: except Exception:
return False plugin = None
if plugin is None:
return target
def _maybe_unlock_alldebrid_url(url: str, config: Optional[Dict[str, Any]]) -> str:
"""Convert AllDebrid protected file URLs into direct streamable links.
When AllDebrid returns `https://alldebrid.com/f/...`, that URL typically requires
authentication. MPV cannot access it without credentials. We transparently call
the AllDebrid API `link/unlock` (using the configured API key) to obtain a direct
URL that MPV can stream.
"""
if not _is_alldebrid_protected_url(url):
return url
cached = _ALLDEBRID_UNLOCK_CACHE.get(url)
if isinstance(cached, str) and cached:
return cached
api_key = _get_alldebrid_api_key(config)
if not api_key:
return url
try: try:
from API.alldebrid import AllDebridClient resolved = plugin.resolve_url(target)
except Exception as exc:
debug(f"Plugin URL resolution failed for {target}: {exc}", file=sys.stderr)
return target
client = AllDebridClient(api_key) return str(resolved or target)
unlocked = client.unlock_link(url)
if isinstance(unlocked, str) and unlocked.strip():
unlocked = unlocked.strip()
_ALLDEBRID_UNLOCK_CACHE[url] = unlocked
return unlocked
except Exception as e:
debug(f"AllDebrid unlock failed for MPV target: {e}", file=sys.stderr)
return url
def _resolve_plugin_playback_path(item: Any, config: Optional[Dict[str, Any]]) -> Optional[str]:
for candidate in _iter_plugin_targets(item):
try:
plugin = get_plugin_for_url(candidate, config or {})
except Exception:
plugin = None
if plugin is None:
continue
try:
resolved = plugin.resolve_playback_path(item)
except Exception as exc:
debug(f"Plugin playback-path resolution failed for {candidate}: {exc}", file=sys.stderr)
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:
@@ -1078,9 +1088,7 @@ def _ensure_ytdl_cookies(config: Optional[Dict[str, Any]] = None) -> None:
cookies_path = None cookies_path = None
try: try:
from tool.ytdlp import YtDlpTool cookiefile = resolve_cookies_path(config or {})
cookiefile = YtDlpTool(config or {}).resolve_cookiefile()
if cookiefile is not None: if cookiefile is not None:
cookies_path = str(cookiefile) cookies_path = str(cookiefile)
except Exception: except Exception:
@@ -1326,7 +1334,7 @@ def _get_playable_path(
"none"}: "none"}:
path = None path = None
manifest_path = resolve_tidal_manifest_path(item) manifest_path = _resolve_plugin_playback_path(item, config)
if manifest_path: if manifest_path:
path = manifest_path path = manifest_path
else: else:
@@ -1542,7 +1550,7 @@ def _queue_items(
# If the target is an AllDebrid protected file URL, unlock it to a direct link for MPV. # If the target is an AllDebrid protected file URL, unlock it to a direct link for MPV.
try: try:
if isinstance(target, str): if isinstance(target, str):
target = _maybe_unlock_alldebrid_url(target, config) target = _resolve_plugin_url(target, config)
except Exception: except Exception:
pass pass
@@ -2591,7 +2599,7 @@ def _start_mpv(
try: try:
needs_mpd_whitelist = False needs_mpd_whitelist = False
for it in items or []: for it in items or []:
mpd = resolve_tidal_manifest_path(it) mpd = _resolve_plugin_playback_path(it, config)
candidate = mpd candidate = mpd
if not candidate: if not candidate:
if isinstance(it, dict): if isinstance(it, dict):
+12 -79
View File
@@ -4,18 +4,14 @@ import shutil
from typing import Any, Dict, List from typing import Any, Dict, List
from SYS.cmdlet_spec import Cmdlet from SYS.cmdlet_spec import Cmdlet
from SYS.config import resolve_cookies_path
from SYS import pipeline as ctx from SYS import pipeline as ctx
from SYS.result_table import Table from SYS.result_table import Table
from SYS.logger import set_debug, debug from SYS.logger import set_debug, debug
from cmdnat._status_shared import ( from cmdnat._status_shared import (
add_startup_check as _add_startup_check, add_startup_check as _add_startup_check,
default_provider_ping_targets as _default_provider_ping_targets, collect_plugin_startup_checks as _collect_plugin_startup_checks,
has_provider as _has_provider,
has_store_subtype as _has_store_subtype, has_store_subtype as _has_store_subtype,
has_tool as _has_tool,
ping_first as _ping_first,
ping_url as _ping_url,
provider_display_name as _provider_display_name,
) )
CMDLET = Cmdlet( CMDLET = Cmdlet(
@@ -95,82 +91,19 @@ def _run(result: Any, args: List[str], config: Dict[str, Any]) -> int:
detail = f"{uval} - {err or 'Unavailable'}" detail = f"{uval} - {err or 'Unavailable'}"
_add_startup_check(startup_table, status, nkey, store="hydrusnetwork", files=files, detail=detail) _add_startup_check(startup_table, status, nkey, store="hydrusnetwork", files=files, detail=detail)
# Providers for check in _collect_plugin_startup_checks(config):
pcfg = config.get("provider", {}) _add_startup_check(
if isinstance(pcfg, dict) and pcfg: startup_table,
from ProviderCore.registry import list_providers, list_search_providers, list_file_providers str(check.get("status") or "UNKNOWN"),
from Provider.metadata_provider import list_metadata_providers str(check.get("name") or "Plugin"),
provider=str(check.get("plugin") or ""),
p_avail = list_providers(config) or {} files=check.get("files"),
s_avail = list_search_providers(config) or {} detail=str(check.get("detail") or ""),
f_avail = list_file_providers(config) or {} )
m_avail = list_metadata_providers(config) or {}
debug(f"Provider registries: providers={list(p_avail.keys())}, search={list(s_avail.keys())}, file={list(f_avail.keys())}, metadata={list(m_avail.keys())}")
already = {"matrix"}
for pname in pcfg.keys():
prov = str(pname).lower()
if prov in already: continue
display = _provider_display_name(prov)
if prov == "alldebrid":
try:
from Provider.alldebrid import _get_debrid_api_key
from API.alldebrid import AllDebridClient
api_key = _get_debrid_api_key(config)
debug(f"AllDebrid configured: api_key_present={bool(api_key)}")
if not api_key:
_add_startup_check(startup_table, "DISABLED", display, provider=prov, detail="Not configured")
else:
client = AllDebridClient(api_key)
_add_startup_check(startup_table, "ENABLED", display, provider=prov, detail=getattr(client, "base_url", "Connected"))
debug(f"AllDebrid client connected: base_url={getattr(client, 'base_url', 'unknown')}")
except Exception as exc:
_add_startup_check(startup_table, "DISABLED", display, provider=prov, detail=str(exc))
debug(f"AllDebrid check failed: {exc}")
already.add(prov)
continue
is_known = prov in p_avail or prov in s_avail or prov in f_avail or prov in m_avail
if not is_known:
_add_startup_check(startup_table, "UNKNOWN", display, provider=prov, detail="Not registered")
debug(f"Provider {prov} not registered")
else:
ok_val = p_avail.get(prov) or s_avail.get(prov) or f_avail.get(prov) or m_avail.get(prov)
detail = "Configured" if ok_val else "Not configured"
ping_targets = _default_provider_ping_targets(prov)
if ping_targets:
debug(f"Provider {prov} ping targets: {ping_targets}")
pok, pdet = _ping_first(ping_targets)
debug(f"Provider {prov} ping result: ok={pok}, detail={pdet}")
detail = pdet if ok_val else f"{detail} | {pdet}"
_add_startup_check(startup_table, "ENABLED" if ok_val else "DISABLED", display, provider=prov, detail=detail)
already.add(prov)
# Matrix
if _has_provider(config, "matrix"):
try:
from Provider.matrix import Matrix
m_prov = Matrix(config)
mcfg = config.get("provider", {}).get("matrix", {})
hs = str(mcfg.get("homeserver") or "").strip()
rid = str(mcfg.get("room_id") or "").strip()
detail = f"{hs} room:{rid}"
valid = False
try:
valid = bool(m_prov.validate())
except Exception as exc:
debug(f"Matrix validate failed: {exc}")
_add_startup_check(startup_table, "ENABLED" if valid else "DISABLED", "Matrix", provider="matrix", detail=detail)
debug(f"Matrix check: homeserver={hs}, room_id={rid}, validate={valid}")
except Exception as exc:
_add_startup_check(startup_table, "DISABLED", "Matrix", provider="matrix", detail=str(exc))
debug(f"Matrix instantiation failed: {exc}")
# Cookies # Cookies
try: try:
from tool.ytdlp import YtDlpTool cf = resolve_cookies_path(config)
cf = YtDlpTool(config).resolve_cookiefile()
_add_startup_check(startup_table, "FOUND" if cf else "MISSING", "Cookies", detail=str(cf) if cf else "Not found") _add_startup_check(startup_table, "FOUND" if cf else "MISSING", "Cookies", detail=str(cf) if cf else "Not found")
debug(f"Cookies: resolved cookiefile={cf}") debug(f"Cookies: resolved cookiefile={cf}")
except Exception as exc: except Exception as exc:
+9 -3
View File
@@ -8,10 +8,18 @@ from SYS.cmdlet_spec import Cmdlet, CmdletArg
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 cmdnat._parsing import has_flag as _has_flag, normalize_to_list as _normalize_to_list 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"
def _get_telegram_provider(config: Dict[str, Any]) -> Any:
provider = get_plugin("telegram", config)
if provider is None:
raise RuntimeError("Telegram plugin is not registered")
return provider
def _extract_chat_id(chat_obj: Any) -> Optional[int]: def _extract_chat_id(chat_obj: Any) -> Optional[int]:
try: try:
if isinstance(chat_obj, dict): if isinstance(chat_obj, dict):
@@ -119,10 +127,8 @@ def _extract_file_path(item: Any) -> Optional[str]:
def _run(_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: def _run(_result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
from Provider.telegram import Telegram
try: try:
provider = Telegram(config) provider = _get_telegram_provider(config)
except Exception as exc: except Exception as exc:
log(f"Telegram not available: {exc}", file=sys.stderr) log(f"Telegram not available: {exc}", file=sys.stderr)
return 1 return 1
+12 -12
View File
@@ -1,13 +1,13 @@
# Provider authoring: ResultTable & provider adapters # Plugin authoring: ResultTable & plugin adapters
This short guide explains how to write providers that integrate with the *strict* ResultTable API: adapters must yield `ResultModel` instances and providers register via `SYS.result_table_adapters.register_provider` with a column specification and a `selection_fn`. This short guide explains how to write plugins that integrate with the *strict* ResultTable API: adapters must yield `ResultModel` instances and plugins register via `SYS.result_table_adapters.register_plugin` with a column specification and a `selection_fn`.
--- ---
## Quick summary ## Quick summary
- Providers register a *provider adapter* (callable that yields `ResultModel`). - Plugins register a *plugin adapter* (callable that yields `ResultModel`).
- Providers must also provide `columns` (static list or factory) and a `selection_fn` that returns CLI args for a selected row. - Plugins must also provide `columns` (static list or factory) and a `selection_fn` that returns CLI args for a selected row.
- For simple HTML table/list scraping, prefer `TableProviderMixin` from `SYS.provider_helpers` to fetch and extract rows using `SYS.html_table.extract_records`. - For simple HTML table/list scraping, prefer `TableProviderMixin` from `SYS.provider_helpers` to fetch and extract rows using `SYS.html_table.extract_records`.
## Runtime dependency policy ## Runtime dependency policy
@@ -21,11 +21,11 @@ This short guide explains how to write providers that integrate with the *strict
## Minimal provider template (copy/paste) ## Minimal provider template (copy/paste)
```py ```py
# Provider/my_provider.py # plugins/my_plugin.py
from typing import Any, Dict, Iterable, List from typing import Any, Dict, Iterable, List
from SYS.result_table_api import ResultModel, ColumnSpec, title_column, metadata_column from SYS.result_table_api import ResultModel, ColumnSpec, title_column, metadata_column
from SYS.result_table_adapters import register_provider from SYS.result_table_adapters import register_plugin
# Example adapter: convert provider-specific items into ResultModel instances # Example adapter: convert provider-specific items into ResultModel instances
SAMPLE_ITEMS = [ SAMPLE_ITEMS = [
@@ -59,8 +59,8 @@ def selection_fn(row: ResultModel) -> List[str]:
return ["-path", row.path] return ["-path", row.path]
return ["-title", row.title or ""] return ["-title", row.title or ""]
# Register provider (done at import time) # Register plugin (done at import time)
register_provider("myprovider", adapter, columns=columns_factory, selection_fn=selection_fn) register_plugin("myprovider", adapter, columns=columns_factory, selection_fn=selection_fn)
``` ```
--- ---
@@ -84,7 +84,7 @@ class MyTableProvider(TableProviderMixin, Provider):
return self.search_table_from_url(url, limit=limit) return self.search_table_from_url(url, limit=limit)
``` ```
`TableProviderMixin.search_table_from_url` returns `ProviderCore.base.SearchResult` entries. If you want to integrate this provider with the strict `ResultTable` registry, add a small adapter that converts `SearchResult` -> `ResultModel` and register it using `register_provider` (see `Provider/vimm.py` for a real example). `TableProviderMixin.search_table_from_url` returns `ProviderCore.base.SearchResult` entries. If you want to integrate this plugin with the strict `ResultTable` registry, add a small adapter that converts `SearchResult` -> `ResultModel` and register it using `register_plugin` (see `Provider/vimm.py` for a real example).
--- ---
@@ -93,7 +93,7 @@ class MyTableProvider(TableProviderMixin, Provider):
- `columns` may be a static `List[ColumnSpec]` or a factory `def cols(rows: List[ResultModel]) -> List[ColumnSpec]` that inspects sample rows. - `columns` may be a static `List[ColumnSpec]` or a factory `def cols(rows: List[ResultModel]) -> List[ColumnSpec]` that inspects sample rows.
- `selection_fn` must accept a `ResultModel` and return a `List[str]` representing CLI args (e.g., `['-path', row.path]`). These args are used by `select` and `@N` expansion. - `selection_fn` must accept a `ResultModel` and return a `List[str]` representing CLI args (e.g., `['-path', row.path]`). These args are used by `select` and `@N` expansion.
**Tip:** for providers that produce downloadable file rows prefer returning explicit URL args (e.g., `['-url', row.path]`) so the selected URL is clearly identified by downstream downloaders and to avoid ambiguous parsing when provider hints (like `-provider`) are present. **Tip:** for plugins that produce downloadable file rows prefer returning explicit URL args (e.g., `['-url', row.path]`) so the selected URL is clearly identified by downstream downloaders and to avoid ambiguous parsing when plugin hints (like `-plugin`) are present.
- Ensure your `ResultModel.source` is set (either in the model or rely on the provider name set by `serialize_row`). - Ensure your `ResultModel.source` is set (either in the model or rely on the provider name set by `serialize_row`).
--- ---
@@ -107,7 +107,7 @@ class MyTableProvider(TableProviderMixin, Provider):
## Testing & examples ## Testing & examples
- Write `tests/test_provider_<name>.py` that imports your provider and verifies `provider.build_table(...)` produces a `ResultTable` (has `.rows` and `.columns`) and that `serialize_rows()` yields dicts with `_selection_args`, `_selection_action` when applicable, and `source`. - Write `tests/test_provider_<name>.py` that imports your provider and verifies `provider.build_table(...)` produces a `ResultTable` (has `.rows` and `.columns`) and that `serialize_rows()` yields dicts with `_selection_args`, `_selection_action` when applicable, and `source`.
- When you need to guarantee a specific CLI stage sequence (e.g., `download-file -url <path> -provider <name>`), call `table.set_row_selection_action(row_index, tokens)` so the serialized payload emits `_selection_action` and the CLI can run the row exactly as intended. - When you need to guarantee a specific CLI stage sequence (e.g., `download-file -url <path> -plugin <name>`), call `table.set_row_selection_action(row_index, tokens)` so the serialized payload emits `_selection_action` and the CLI can run the row exactly as intended.
- For table providers you can test `search_table_from_url` using a local HTML fixture or by mocking `HTTPClient` to return a small sample page. - For table providers you can test `search_table_from_url` using a local HTML fixture or by mocking `HTTPClient` to return a small sample page.
- If you rely on pandas, add a test that monkeypatches `sys.modules['pandas']` to a simple shim to validate the pandas path. - If you rely on pandas, add a test that monkeypatches `sys.modules['pandas']` to a simple shim to validate the pandas path.
@@ -119,7 +119,7 @@ from Provider import example_provider
def test_example_provider_registration(): def test_example_provider_registration():
provider = get_provider("example") plugin = get_plugin("example")
rows = list(provider.adapter(example_provider.SAMPLE_ITEMS)) rows = list(provider.adapter(example_provider.SAMPLE_ITEMS))
assert rows and rows[0].title assert rows and rows[0].title
cols = provider.get_columns(rows) cols = provider.get_columns(rows)
+20 -21
View File
@@ -1,23 +1,23 @@
# Provider Development Guide # Plugin Development Guide
## 🎯 Purpose ## 🎯 Purpose
This guide describes how to write, test, and register a provider so the application can discover and use it as a pluggable component. This guide describes how to write, test, and register a plugin so the application can discover and use it as a pluggable component.
> Keep provider code small, focused, and well-tested. Use existing providers as examples. > Keep plugin code small, focused, and well-tested. Built-in plugins live in `Provider/` and external drop-in plugins live under `plugins/`.
--- ---
## 🔧 Anatomy of a Provider ## 🔧 Anatomy of a Plugin
A provider is a Python class that extends `ProviderCore.base.Provider` and implements a few key methods and attributes. A plugin is a Python class that extends `ProviderCore.base.Provider` and implements a few key methods and attributes.
Minimum expectations: Minimum expectations:
- `class MyProvider(Provider):` — subclass the base provider - `class MyPlugin(Provider):` — subclass the base plugin class
- `URL` / `URL_DOMAINS` or `url_patterns()` — to let the registry route URLs - `URL` / `URL_DOMAINS` or `url_patterns()` — to let the registry route URLs
- `validate(self) -> bool` — return True when provider is configured and usable - `validate(self) -> bool` — return True when the plugin is configured and usable
- `search(self, query, limit=50, filters=None, **kwargs)` — return a list of `SearchResult` - `search(self, query, limit=50, filters=None, **kwargs)` — return a list of `SearchResult`
Optional but common: Optional but common:
- `download(self, result: SearchResult, output_dir: Path) -> Optional[Path]` — download a provider result - `download(self, result: SearchResult, output_dir: Path) -> Optional[Path]` — download a plugin result
- `selector(self, selected_items, *, ctx, stage_is_last=True, **kwargs) -> bool` — handle `@N` selections - `selector(self, selected_items, *, ctx, stage_is_last=True, **kwargs) -> bool` — handle `@N` selections
- `download_url(self, url, output_dir, progress_cb=None)` — direct URL-handling helper - `download_url(self, url, output_dir, progress_cb=None)` — direct URL-handling helper
@@ -71,8 +71,8 @@ class HelloProvider(Provider):
--- ---
## ⬇️ Implementing download() and download_url() ## ⬇️ Implementing download() and download_url()
- Prefer provider `download(self, result, output_dir)` for piped provider items. - Prefer plugin `download(self, result, output_dir)` for piped plugin items.
- For provider-provided URLs, implement `download_url` to allow `download-file` to route downloads through providers. - For plugin-provided URLs, implement `download_url` to allow `download-file` to route downloads through plugins.
- Use the repo `_download_direct_file` helper for HTTP downloads when possible. - Use the repo `_download_direct_file` helper for HTTP downloads when possible.
Example download(): Example download():
@@ -90,12 +90,12 @@ def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
--- ---
## 🧭 URL routing ## 🧭 URL routing
Providers can declare: Plugins can declare:
- `URL = ("magnet:",)` or similar prefix list - `URL = ("magnet:",)` or similar prefix list
- `URL_DOMAINS = ("example.com",)` to match hosts - `URL_DOMAINS = ("example.com",)` to match hosts
- Or override `@classmethod def url_patterns(cls):` to combine static and dynamic patterns - Or override `@classmethod def url_patterns(cls):` to combine static and dynamic patterns
The registry uses these to match `download-file <url>` or to pick which provider should handle the URL. The registry uses these to match `download-file <url>` or to pick which plugin should handle the URL.
--- ---
@@ -106,8 +106,8 @@ The registry uses these to match `download-file <url>` or to pick which provider
--- ---
## 🧪 Testing providers ## 🧪 Testing plugins
- Keep tests small and local. Create `tests/test_provider_<name>.py`. - Keep tests small and local. Create `tests/test_provider_<name>.py` or another tracked test target.
- Test `search()` with mock HTTP responses (use `requests-mock` or similar). - Test `search()` with mock HTTP responses (use `requests-mock` or similar).
- Test `download()` using a temp directory and a small file server or by mocking `_download_direct_file`. - Test `download()` using a temp directory and a small file server or by mocking `_download_direct_file`.
- Test `selector()` by constructing a fake result and `ctx` object. - Test `selector()` by constructing a fake result and `ctx` object.
@@ -125,10 +125,9 @@ pytest -q
--- ---
## 📦 Registration & packaging ## 📦 Registration & packaging
- Add your provider module under `Provider/` and ensure it is imported by module package initialization. Common approach: - Built-in plugins live under `Provider/` and are auto-discovered from that package.
- Place file `Provider/myprovider.py` - External user plugins can be dropped into `plugins/` or any directory listed in `MM_PLUGIN_PATH` / `MEDEIA_PLUGIN_PATH`.
- Ensure `Provider/__init__.py` imports the module (or the registry auto-discovers by package import) - Plugin authors should import from `ProviderCore.*`.
- If the project has a central provider registry, add lookup helpers there (e.g., `ProviderCore/registry.py`). Usually providers register themselves at import time.
--- ---
@@ -147,19 +146,19 @@ pytest -q
- [ ] Provide `URL` / `URL_DOMAINS` or `url_patterns()` for routing - [ ] Provide `URL` / `URL_DOMAINS` or `url_patterns()` for routing
- [ ] Add `download()` or `download_url()` for piped/passed URL downloads - [ ] Add `download()` or `download_url()` for piped/passed URL downloads
- [ ] Add tests under `tests/` - [ ] Add tests under `tests/`
- [ ] Add module to `Provider/` package and ensure import/registration - [ ] Add the plugin module to `Provider/` for built-ins, or drop it into `plugins/` for plug-and-play user installs
--- ---
## 🔗 Further reading ## 🔗 Further reading
- See existing providers in `Provider/` for patterns and edge cases. - See existing built-in plugins in `Provider/` for patterns and edge cases.
- Check `API/` helpers for HTTP and debrid clients. - Check `API/` helpers for HTTP and debrid clients.
--- ---
If you'd like, I can: If you'd like, I can:
- Add an example provider file under `Provider/` as a template (see `Provider/hello_provider.py`), and - Add an example plugin file under `Provider/` as a template (see `Provider/hello_provider.py`), and
- Create unit tests for it (see `tests/test_provider_hello.py`). - Create unit tests for it (see `tests/test_provider_hello.py`).
I have added a minimal example provider and tests in this repository; use them as a starting point for new providers. I have added a minimal example provider and tests in this repository; use them as a starting point for new providers.
+6 -6
View File
@@ -40,7 +40,7 @@ from SYS.result_table import ResultTable
table = ResultTable("Provider: X result").set_preserve_order(True) table = ResultTable("Provider: X result").set_preserve_order(True)
table.set_table("provider_name") table.set_table("provider_name")
table.set_table_metadata({"provider":"provider_name","view":"folders"}) table.set_table_metadata({"provider":"provider_name","view":"folders"})
table.set_source_command("search-file", ["-provider","provider_name","query"]) table.set_source_command("search-file", ["-plugin","provider_name","query"])
for r in results: for r in results:
table.add_result(r) # r can be a SearchResult, dict, or PipeObject table.add_result(r) # r can be a SearchResult, dict, or PipeObject
@@ -82,13 +82,13 @@ Example commands:
``` ```
# List magnets in your account # List magnets in your account
search-file -provider alldebrid "*" search-file -plugin alldebrid "*"
# Open magnet id 123 and list its files # Open magnet id 123 and list its files
search-file -provider alldebrid -open 123 "*" search-file -plugin alldebrid -open 123 "*"
# Or expand via @ selection (selector handles drilling): # Or expand via @ selection (selector handles drilling):
search-file -provider alldebrid "*" search-file -plugin alldebrid "*"
@3 # selector will open the magnet referenced by row #3 and show the file table @3 # selector will open the magnet referenced by row #3 and show the file table
``` ```
@@ -147,7 +147,7 @@ Selection & download flows
``` ```
# Expand magnet and add first file to local directory # Expand magnet and add first file to local directory
search-file -provider alldebrid "*" search-file -plugin alldebrid "*"
@3 # view files @3 # view files
@1 | add-file -path C:\mydir @1 | add-file -path C:\mydir
``` ```
@@ -167,7 +167,7 @@ Example usage:
``` ```
# Search for an artist # Search for an artist
search-file -provider bandcamp "artist:radiohead" search-file -plugin bandcamp "artist:radiohead"
# Select an artist row to expand into releases # Select an artist row to expand into releases
@1 @1
+7 -7
View File
@@ -1,22 +1,22 @@
Selector & provider-table usage Selector & plugin-table usage
This project provides a small provider/table/selector flow that allows providers This project provides a small plugin/table/selector flow that allows plugins
and cmdlets to interact via a simple, pipable API. and cmdlets to interact via a simple, pipable API.
Key ideas Key ideas
- `provider-table` renders a provider result set and *emits* pipeline-friendly dicts for each row. Each emitted item includes `_selection_args`, a list of args the provider suggests for selecting that row (e.g., `['-path', '/tmp/file']`). - `plugin-table` renders a plugin result set and *emits* pipeline-friendly dicts for each row. Each emitted item includes `_selection_args`, a list of args the plugin suggests for selecting that row (e.g., `['-path', '/tmp/file']`).
- Use the `@N` syntax to select an item from a table and chain it to the next cmdlet. - Use the `@N` syntax to select an item from a table and chain it to the next cmdlet.
Example: Example:
provider-table -provider example -sample | @1 | add-file -store default plugin-table -plugin example -sample | @1 | add-file -store default
What providers must implement What plugins must implement
- An adapter that yields `ResultModel` objects (breaking API). - An adapter that yields `ResultModel` objects (breaking API).
- Optionally supply a `columns` factory and `selection_fn` (see `Provider/example_provider.py`). - Optionally supply a `columns` factory and `selection_fn` (see `Provider/example_provider.py`).
Implementation notes Implementation notes
- `provider-table` emits dicts like `{ 'title': ..., 'path': ..., 'metadata': ..., '_selection_args': [...] }`. - `plugin-table` emits dicts like `{ 'title': ..., 'path': ..., 'metadata': ..., '_selection_args': [...] }`.
- Selection syntax (`@1`) will prefer `_selection_args` if present; otherwise it will fall back to provider selection logic or sensible defaults (`-path` or `-title`). - Selection syntax (`@1`) will prefer `_selection_args` if present; otherwise it will fall back to plugin selection logic or sensible defaults (`-path` or `-title`).
This design keeps the selector-focused UX small and predictable while enabling full cmdlet interoperability via piping and `-run-cmd`. This design keeps the selector-focused UX small and predictable while enabling full cmdlet interoperability via piping and `-run-cmd`.
+39
View File
@@ -0,0 +1,39 @@
# External Plugins
Drop user plugins in this folder to make them available to the app without editing the built-in `Provider/` package.
Supported discovery paths:
- `plugins/` in the repo root
- `plugins/` in the current working directory
- Any directory listed in `MM_PLUGIN_PATH`
- Any directory listed in `MEDEIA_PLUGIN_PATH`
Plugin module rules:
- A plugin can be a single `.py` file or a package directory with `__init__.py`.
- Define a class that inherits from `ProviderCore.base.Provider`.
- Give it a stable name using `PLUGIN_NAME` or the class name.
Example skeleton:
```python
from ProviderCore.base import Provider, SearchResult
class MyPlugin(Provider):
PLUGIN_NAME = "myplugin"
URL_DOMAINS = ("example.com",)
def search(self, query, limit=50, filters=None, **kwargs):
text = str(query or "").strip()
if not text:
return []
return [
SearchResult(
table="myplugin",
title=f"Result for {text}",
path=f"https://example.com/{text}",
)
]
```
Built-in plugins still live in `Provider/`.
+2 -2
View File
@@ -33,9 +33,9 @@ Medios-Macina is a API driven file media manager and virtual toolbox capable of
<li><i>no opening of folders neccessary! You can add multiple tags to a file and use the search engine to immediately find and retrieve that file your looking for</i></li> <li><i>no opening of folders neccessary! You can add multiple tags to a file and use the search engine to immediately find and retrieve that file your looking for</i></li>
<li><b>Flexible syntax structure:</b> chain commands with `|` and select options from tables with `@N`.</li> <li><b>Flexible syntax structure:</b> chain commands with `|` and select options from tables with `@N`.</li>
<li><b>Multiple file stores:</b> *HYDRUSNETWORK* <li><b>Multiple file stores:</b> *HYDRUSNETWORK*
- **Provider plugin integration:** *YOUTUBE, OPENLIBRARY, INTERNETARCHIVE, SOULSEEK, LIBGEN, ALLDEBRID, TELEGRAM, BANDCAMP*</li> - **Plugin integration:** *YOUTUBE, OPENLIBRARY, INTERNETARCHIVE, SOULSEEK, LIBGEN, ALLDEBRID, TELEGRAM, BANDCAMP*</li>
<li><b>Module Mixing:</b> *[Playwright](https://github.com/microsoft/playwright), [yt-dlp](https://github.com/yt-dlp/yt-dlp), [typer](https://github.com/fastapi/typer)*</li> <li><b>Module Mixing:</b> *[Playwright](https://github.com/microsoft/playwright), [yt-dlp](https://github.com/yt-dlp/yt-dlp), [typer](https://github.com/fastapi/typer)*</li>
<li><b>Optional stacks:</b> Telethon (Telegram), aioslsk (Soulseek), and the FlorenceVision tooling install automatically when you configure the corresponding provider/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>
</ul> </ul>