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:
+15
-15
@@ -5,6 +5,7 @@ from importlib import import_module, reload as reload_module
|
||||
from types import ModuleType
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
from ProviderCore.registry import get_plugin
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -370,22 +371,21 @@ def get_cmdlet_arg_choices(
|
||||
token = matrix_conf.get("access_token")
|
||||
if hs and token:
|
||||
try:
|
||||
from Provider.matrix import Matrix
|
||||
|
||||
try:
|
||||
m = Matrix(config)
|
||||
rooms = m.list_rooms(room_ids=ids)
|
||||
choices = []
|
||||
for r in rooms or []:
|
||||
name = str(r.get("name") or "").strip()
|
||||
rid = str(r.get("room_id") or "").strip()
|
||||
choices.append(name or rid)
|
||||
if choices:
|
||||
return choices
|
||||
except Exception as exc:
|
||||
logger.exception("Matrix provider failed while listing rooms: %s", exc)
|
||||
provider = get_plugin("matrix", config)
|
||||
if provider is not None:
|
||||
try:
|
||||
rooms = provider.list_rooms(room_ids=ids)
|
||||
choices = []
|
||||
for r in rooms or []:
|
||||
name = str(r.get("name") or "").strip()
|
||||
rid = str(r.get("room_id") or "").strip()
|
||||
choices.append(name or rid)
|
||||
if choices:
|
||||
return choices
|
||||
except Exception as exc:
|
||||
logger.exception("Matrix provider failed while listing rooms: %s", 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:
|
||||
logger.exception("Failed to resolve matrix rooms: %s", exc)
|
||||
|
||||
|
||||
+16
-6
@@ -90,10 +90,10 @@ class SharedArgs:
|
||||
description="http parser",
|
||||
)
|
||||
|
||||
PROVIDER = CmdletArg(
|
||||
name="provider",
|
||||
PLUGIN = CmdletArg(
|
||||
name="plugin",
|
||||
type="string",
|
||||
description="selects provider",
|
||||
description="selects plugin",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -284,7 +284,13 @@ class Cmdlet:
|
||||
return {f"-{arg_name}", f"--{arg_name}"}
|
||||
|
||||
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(
|
||||
@@ -335,8 +341,12 @@ def parse_cmdlet_args(
|
||||
positional_args.append(spec)
|
||||
|
||||
arg_spec_map[canonical_key] = canonical_name
|
||||
arg_spec_map[f"-{canonical_name}".lower()] = canonical_name
|
||||
arg_spec_map[f"--{canonical_name}".lower()] = canonical_name
|
||||
try:
|
||||
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
|
||||
positional_index = 0
|
||||
|
||||
+10
-29
@@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
try: # Optional; used when available for richer metadata fetches
|
||||
@@ -2213,40 +2214,20 @@ def enrich_playlist_entries(entries: list, extractor: str) -> list:
|
||||
Returns:
|
||||
List of enriched entry dicts
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from tool.ytdlp import is_url_supported_by_ytdlp
|
||||
|
||||
if not entries:
|
||||
return entries
|
||||
|
||||
enriched = []
|
||||
for entry in entries:
|
||||
# If entry has a direct URL, fetch its full metadata
|
||||
entry_url = entry.get("url")
|
||||
if entry_url and is_url_supported_by_ytdlp(entry_url):
|
||||
try:
|
||||
import yt_dlp
|
||||
plugin = get_plugin("ytdlp", {})
|
||||
if plugin is None:
|
||||
return entries
|
||||
|
||||
ydl_opts: Any = {
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"skip_download": True,
|
||||
"noprogress": True,
|
||||
"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)
|
||||
try:
|
||||
enriched = plugin.enrich_playlist_entries(entries, extractor=extractor)
|
||||
except Exception:
|
||||
logger.exception("Failed to enrich playlist entries for extractor: %s", extractor)
|
||||
return entries
|
||||
|
||||
# Fallback to original entry if fetch failed
|
||||
enriched.append(entry)
|
||||
|
||||
return enriched
|
||||
return enriched if isinstance(enriched, list) else entries
|
||||
|
||||
|
||||
def format_playlist_entry(entry: Dict[str,
|
||||
|
||||
+126
-79
@@ -1505,9 +1505,9 @@ class PipelineExecutor:
|
||||
"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
|
||||
# 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:
|
||||
meta = (
|
||||
current_table.get_table_metadata()
|
||||
@@ -1517,56 +1517,58 @@ class PipelineExecutor:
|
||||
except Exception:
|
||||
meta = None
|
||||
if isinstance(meta, dict):
|
||||
_add(meta.get("plugin"))
|
||||
_add(meta.get("provider"))
|
||||
except Exception:
|
||||
logger.exception("Failed to inspect current_table/table metadata in _maybe_run_class_selector")
|
||||
|
||||
for item in selected_items or []:
|
||||
if isinstance(item, dict):
|
||||
_add(item.get("plugin"))
|
||||
_add(item.get("provider"))
|
||||
_add(item.get("store"))
|
||||
_add(item.get("table"))
|
||||
else:
|
||||
_add(getattr(item, "plugin", None))
|
||||
_add(getattr(item, "provider", None))
|
||||
_add(getattr(item, "store", None))
|
||||
_add(getattr(item, "table", None))
|
||||
|
||||
try:
|
||||
from ProviderCore.registry import get_provider, is_known_provider_name
|
||||
from ProviderCore.registry import get_plugin, is_known_plugin_name
|
||||
except Exception:
|
||||
get_provider = None # type: ignore
|
||||
is_known_provider_name = None # type: ignore
|
||||
get_plugin = 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")
|
||||
# when that prefix is a registered provider name.
|
||||
if is_known_provider_name is not None:
|
||||
# If we have a table-type like "tidal.album", also try its plugin prefix ("tidal")
|
||||
# when that prefix is a registered plugin name.
|
||||
if is_known_plugin_name is not None:
|
||||
try:
|
||||
for key in list(candidates):
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
if "." not in key:
|
||||
continue
|
||||
if is_known_provider_name(key):
|
||||
if is_known_plugin_name(key):
|
||||
continue
|
||||
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)
|
||||
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:
|
||||
try:
|
||||
if is_known_provider_name is not None and (
|
||||
not is_known_provider_name(key)):
|
||||
if is_known_plugin_name is not None and (
|
||||
not is_known_plugin_name(key)):
|
||||
continue
|
||||
except Exception:
|
||||
# If the predicate fails for any reason, fall back to legacy behavior.
|
||||
logger.exception("is_known_provider_name predicate failed for key %s; falling back", key)
|
||||
logger.exception("is_known_plugin_name predicate failed for key %s; falling back", key)
|
||||
try:
|
||||
provider = get_provider(key, config)
|
||||
provider = get_plugin(key, config)
|
||||
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
|
||||
selector = getattr(provider, "selector", None)
|
||||
if selector is None:
|
||||
@@ -1583,6 +1585,92 @@ class PipelineExecutor:
|
||||
if handled:
|
||||
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] = []
|
||||
for item in selected_items or []:
|
||||
if isinstance(item, dict):
|
||||
@@ -1998,10 +2086,10 @@ class PipelineExecutor:
|
||||
# IMPORTANT: Put selected row args *before* source_args.
|
||||
# Rationale: The cmdlet argument parser treats the *first* unknown
|
||||
# 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
|
||||
# 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
|
||||
# as the positional URL and avoid this class of parsing errors.
|
||||
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")
|
||||
return False, None
|
||||
|
||||
# Provider selection expansion (non-terminal): allow certain provider tables
|
||||
# (e.g. tidal.album) to expand to multiple downstream items when the user
|
||||
# pipes into another stage (e.g. @N | .mpv or @N | add-file).
|
||||
table_type_hint = None
|
||||
try:
|
||||
table_type_hint = (
|
||||
stage_table.table
|
||||
if stage_table is not None and hasattr(stage_table, "table")
|
||||
else None
|
||||
if stages:
|
||||
expanded = PipelineExecutor._maybe_expand_plugin_selection(
|
||||
filtered,
|
||||
ctx=ctx,
|
||||
config=config,
|
||||
stage_table=stage_table,
|
||||
)
|
||||
except Exception:
|
||||
table_type_hint = None
|
||||
|
||||
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 expanded:
|
||||
filtered = expanded
|
||||
|
||||
if PipelineExecutor._maybe_run_class_selector(
|
||||
ctx,
|
||||
@@ -2177,6 +2214,16 @@ class PipelineExecutor:
|
||||
except Exception:
|
||||
logger.exception("Failed to determine current_table for selection auto-insert; defaulting to 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
|
||||
try:
|
||||
if isinstance(table_type_hint, str) and table_type_hint.strip():
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
"""Provider registry for ResultTable API (breaking, strict 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.
|
||||
"""
|
||||
"""Plugin registry for the strict ResultTable API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -18,7 +12,7 @@ SelectionFn = Callable[[ResultModel], List[str]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Provider:
|
||||
class Plugin:
|
||||
name: str
|
||||
adapter: ProviderAdapter
|
||||
# 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]:
|
||||
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):
|
||||
rows_list = list(rows) if rows is not None else []
|
||||
@@ -37,13 +31,13 @@ class Provider:
|
||||
cols = list(self.columns)
|
||||
|
||||
if not cols:
|
||||
raise ValueError(f"provider '{self.name}' produced no columns")
|
||||
raise ValueError(f"plugin '{self.name}' produced no columns")
|
||||
|
||||
return cols
|
||||
|
||||
def selection_args(self, row: ResultModel) -> List[str]:
|
||||
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)))
|
||||
return sel
|
||||
@@ -54,7 +48,7 @@ class Provider:
|
||||
try:
|
||||
rows = [ensure_result_model(r) for r in self.adapter(items)]
|
||||
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)
|
||||
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]
|
||||
|
||||
|
||||
_PROVIDERS: Dict[str, Provider] = {}
|
||||
_PLUGINS: Dict[str, Plugin] = {}
|
||||
|
||||
|
||||
def register_provider(
|
||||
def register_plugin(
|
||||
name: str,
|
||||
adapter: ProviderAdapter,
|
||||
*,
|
||||
columns: Union[List[ColumnSpec], ColumnFactory],
|
||||
selection_fn: SelectionFn,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Provider:
|
||||
) -> Plugin:
|
||||
name = str(name or "").strip().lower()
|
||||
if not name:
|
||||
raise ValueError("provider name required")
|
||||
if name in _PROVIDERS:
|
||||
raise ValueError(f"provider already registered: {name}")
|
||||
raise ValueError("plugin name required")
|
||||
if name in _PLUGINS:
|
||||
raise ValueError(f"plugin already registered: {name}")
|
||||
if columns is None:
|
||||
raise ValueError("provider registration requires columns")
|
||||
raise ValueError("plugin registration requires columns")
|
||||
if selection_fn is None:
|
||||
raise ValueError("provider registration requires selection_fn")
|
||||
p = Provider(name=name, adapter=adapter, columns=columns, selection_fn=selection_fn, metadata=metadata)
|
||||
_PROVIDERS[name] = p
|
||||
return p
|
||||
raise ValueError("plugin registration requires selection_fn")
|
||||
plugin = Plugin(name=name, adapter=adapter, columns=columns, selection_fn=selection_fn, metadata=metadata)
|
||||
_PLUGINS[name] = plugin
|
||||
return plugin
|
||||
|
||||
|
||||
def get_provider(name: str) -> Provider:
|
||||
def get_plugin(name: str) -> Plugin:
|
||||
normalized = str(name or "").lower()
|
||||
if normalized not in _PROVIDERS:
|
||||
raise KeyError(f"provider not registered: {name}")
|
||||
return _PROVIDERS[normalized]
|
||||
if normalized not in _PLUGINS:
|
||||
raise KeyError(f"plugin not registered: {name}")
|
||||
return _PLUGINS[normalized]
|
||||
|
||||
|
||||
def list_providers() -> List[str]:
|
||||
return list(_PROVIDERS.keys())
|
||||
def list_plugins() -> List[str]:
|
||||
return list(_PLUGINS.keys())
|
||||
|
||||
+3
-3
@@ -148,7 +148,7 @@ def show_store_config_panel(
|
||||
|
||||
|
||||
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.console import Group
|
||||
|
||||
@@ -164,13 +164,13 @@ def show_available_providers_panel(provider_names: List[str]) -> None:
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
panel = Panel(
|
||||
group,
|
||||
title="[bold green]Configured Providers[/bold green]",
|
||||
title="[bold green]Configured Plugins[/bold green]",
|
||||
border_style="green",
|
||||
padding=(1, 2)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user