#!/usr/bin/env python3 """Plugin Manager CLI — scan, list, install, remove, and manage audio plugins. Usage: plugin-mgr scan Full scan and registry sync plugin-mgr list List all registered plugins plugin-mgr list --format=lv2 List by format plugin-mgr list --category=reverb List by category plugin-mgr search Search plugins plugin-mgr info Show plugin details plugin-mgr install-local Install from local path plugin-mgr install-nam Install local .nam model plugin-mgr download-nam Download .nam model plugin-mgr remove Remove plugin (registry only) plugin-mgr remove --files Remove plugin + delete files plugin-mgr enable Enable a disabled plugin plugin-mgr disable Disable a plugin plugin-mgr blacklist Blacklist a plugin plugin-mgr unblacklist Remove from blacklist plugin-mgr blacklist-list Show all blacklist rules plugin-mgr blacklist-add Add a user blacklist rule plugin-mgr nam-list List installed NAM models plugin-mgr stats Show registry statistics plugin-mgr vacuum Compact/optimize the database """ from __future__ import annotations import argparse import os import sys import textwrap from pathlib import Path # Ensure the src directory is on the path _project_root = Path(__file__).resolve().parent.parent _src = _project_root / "src" if str(_src) not in sys.path: sys.path.insert(0, str(_src)) from plugin import ( PluginManager, PluginCategory, PluginFormat, PluginStatus, PluginBlacklist, BlacklistEntry, CATEGORY_LABELS, BUILTIN_BLACKLIST, ) # ── CLI argument definitions ───────────────────────────────────────────────── def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="plugin-mgr", description="RPi Mixer Plugin Manager — scan, register, and manage audio plugins", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=textwrap.dedent("""\ Examples: plugin-mgr scan plugin-mgr list --format=lv2 plugin-mgr list --category=reverb plugin-mgr search "tube amp" plugin-mgr install-nam ~/downloads/my-tone.nam plugin-mgr stats """), ) sp = parser.add_subparsers(dest="command", help="Command to execute") # scan sp.add_parser("scan", help="Scan filesystem and sync registry") # list list_p = sp.add_parser("list", help="List registered plugins") list_p.add_argument("--format", "-f", help="Filter by format (lv2, vst3, ladspa, nam)") list_p.add_argument("--category", "-c", help="Filter by category (reverb, delay, etc.)") list_p.add_argument("--loadable", "-l", action="store_true", help="Only show loadable plugins") list_p.add_argument("--verbose", "-v", action="store_true", help="Show ports and metadata") # search search_p = sp.add_parser("search", help="Search plugins by name/description") search_p.add_argument("query", help="Search query") search_p.add_argument("--verbose", "-v", action="store_true", help="Verbose output") # info info_p = sp.add_parser("info", help="Show detailed plugin info") info_p.add_argument("plugin", help="Plugin URI or name") # install-local install_p = sp.add_parser("install-local", help="Install plugin from local path") install_p.add_argument("path", help="Path to plugin bundle directory or file") install_p.add_argument("--format", "-f", default="lv2", help="Plugin format (default: lv2)") # install-nam nam_inst_p = sp.add_parser("install-nam", help="Install local .nam model") nam_inst_p.add_argument("path", help="Path to .nam file") nam_inst_p.add_argument("--name", "-n", help="Name for the model") # download-nam nam_dl_p = sp.add_parser("download-nam", help="Download .nam model from URL") nam_dl_p.add_argument("url", help="Download URL") nam_dl_p.add_argument("--name", "-n", help="Name for the model") # nam-list sp.add_parser("nam-list", help="List installed NAM models") # remove remove_p = sp.add_parser("remove", help="Remove plugin from registry") remove_p.add_argument("plugin", help="Plugin URI or name") remove_p.add_argument("--files", action="store_true", help="Also delete plugin files from disk") # enable / disable enable_p = sp.add_parser("enable", help="Enable a disabled plugin") enable_p.add_argument("plugin", help="Plugin URI or name") disable_p = sp.add_parser("disable", help="Disable a plugin") disable_p.add_argument("plugin", help="Plugin URI or name") # blacklist bl_p = sp.add_parser("blacklist", help="Blacklist a plugin") bl_p.add_argument("plugin", help="Plugin URI or name") unbl_p = sp.add_parser("unblacklist", help="Remove plugin from blacklist") unbl_p.add_argument("plugin", help="Plugin URI or name") # blacklist-list bl_list = sp.add_parser("blacklist-list", help="Show all blacklist rules") bl_list.add_argument("--builtin", action="store_true", help="Only show built-in rules") bl_list.add_argument("--user", action="store_true", help="Only show user-defined rules") # blacklist-add bl_add = sp.add_parser("blacklist-add", help="Add a user blacklist rule") bl_add.add_argument("pattern", help="Glob pattern to match (e.g., '*BrokenPlugin*')") bl_add.add_argument("--field", default="name", help="Field to match: uri, name, path (default: name)") bl_add.add_argument("--reason", "-r", default="User-defined blacklist", help="Reason for blacklisting") bl_add.add_argument("--severity", default="block", choices=["block", "warn"], help="Severity (default: block)") # stats sp.add_parser("stats", help="Show registry statistics") # vacuum sp.add_parser("vacuum", help="Compact and optimize the database") return parser # ── Output helpers ─────────────────────────────────────────────────────────── def _fmt_plugin_short(p) -> str: """One-line plugin summary.""" status_icon = { PluginStatus.ACTIVE: "[+]", PluginStatus.DISABLED: "[-]", PluginStatus.BLACKLISTED: "[!]", PluginStatus.STALE: "[?]", PluginStatus.ERROR: "[E]", PluginStatus.REMOVED: " ", }.get(p.status, "[ ]") cats = ", ".join(c.value for c in p.categories[:3]) if len(p.categories) > 3: cats += ", …" io_str = f"{p.audio_inputs}i/{p.audio_outputs}o" if p.midi_inputs or p.midi_outputs: io_str += f" MIDI:{p.midi_inputs}i/{p.midi_outputs}o" nam_tag = "" if p.nam_model_size: compat = "✓" if p.rpi4b_known_good else "⚠" if p.rpi4b_known_broken else "?" nam_tag = f" [NAM/{p.nam_model_size} {compat}]" cpu_str = f" CPU~{p.estimated_cpu_pct:.0f}%" if p.estimated_cpu_pct else "" rpi_tag = " [RPi4B✓]" if p.rpi4b_known_good else " [RPi4B⚠]" if p.rpi4b_known_broken else "" return ( f"{status_icon} {p.meta.name:<30} " f"{p.meta.format.value:<6} {io_str:<12} " f"[{cats}]{nam_tag}{rpi_tag}{cpu_str}" ) def _fmt_plugin_verbose(p) -> str: """Detailed plugin info block.""" lines = [ f"Name: {p.meta.name}", f"URI: {p.meta.uri}", f"Format: {p.meta.format.value}", f"Version: {p.meta.version or '—'}", f"Author: {p.meta.author or '—'}", f"License: {p.meta.license or '—'}", f"Description: {p.meta.description or '—'}", f"Homepage: {p.meta.homepage or '—'}", f"Project: {p.meta.project or '—'}", f"Categories: {', '.join(CATEGORY_LABELS.get(c, c.value) for c in p.categories)}", f"Status: {p.status.value}", "", f"Audio I/O: {p.audio_inputs} in, {p.audio_outputs} out", f"MIDI I/O: {p.midi_inputs} in, {p.midi_outputs} out", f"Bundle: {p.bundle_path or '—'}", f"Library: {p.library_path or '—'}", f"Arch: {p.meta.arch or '—'} (ARM optimized: {'yes' if p.meta.arm_optimized else 'no'})", "", ] if p.rpi4b_known_good: lines.append("RPi4B: ✓ Verified working") elif p.rpi4b_known_broken: lines.append("RPi4B: ⚠ Known broken") if p.estimated_cpu_pct: lines.append(f"Est. CPU: ~{p.estimated_cpu_pct:.1f}%") if p.estimated_ram_mb: lines.append(f"Est. RAM: ~{p.estimated_ram_mb:.0f} MB") if p.nam_model_path: lines.append(f"") lines.append(f"NAM Model: {p.nam_model_path}") lines.append(f"Model Size: {p.nam_model_size}") if p.control_ports: lines.append(f"") lines.append(f"Control Ports ({len(p.control_ports)}):") for port in p.control_ports[:20]: lines.append(f" [{port.index:3d}] {port.name:<25} {port.direction:6} " f"{port.minimum:7.2f} .. {port.maximum:7.2f} (def: {port.default:.2f})") if len(p.control_ports) > 20: lines.append(f" … and {len(p.control_ports) - 20} more") if p.error_message: lines.append(f"") lines.append(f"Error: {p.error_message}") if p.scanned_at: from datetime import datetime lines.append(f"") lines.append(f"Scanned: {datetime.fromtimestamp(p.scanned_at).strftime('%Y-%m-%d %H:%M:%S')}") return "\n".join(lines) # ── Resolve plugin by URI or name ──────────────────────────────────────────── def _resolve(mgr: PluginManager, identifier: str): """Resolve a plugin by URI or name (case-insensitive).""" plugin = mgr.registry.get(identifier) if plugin: return plugin plugin = mgr.registry.get_by_name(identifier) if plugin: return plugin # Try partial name match for p in mgr.registry.list_all(): if identifier.lower() in p.meta.name.lower(): return p return None # ── Main ───────────────────────────────────────────────────────────────────── def main(): parser = build_parser() args = parser.parse_args() if args.command is None: parser.print_help() sys.exit(1) mgr = PluginManager() # ── scan ───────────────────────────────────────────────────────────── if args.command == "scan": print("Scanning filesystem for audio plugins…") result = mgr.sync() print(f" New: {result['inserted']}") print(f" Updated: {result['updated']}") print(f" Stale: {result['stale']}") print(f"Done.") # ── list ───────────────────────────────────────────────────────────── elif args.command == "list": fmt = PluginFormat(args.format) if args.format else None cat = PluginCategory(args.category) if args.category else None if fmt: plugins = mgr.registry.list_by_format(fmt) elif cat: plugins = mgr.registry.list_by_category(cat) else: plugins = mgr.registry.list_all() if args.loadable: plugins = [p for p in plugins if p.is_loadable] if not plugins: print("No plugins found.") return for p in plugins: if args.verbose: print(_fmt_plugin_verbose(p)) print("-" * 72) else: print(_fmt_plugin_short(p)) print(f"\n{len(plugins)} plugin(s)") # ── search ─────────────────────────────────────────────────────────── elif args.command == "search": plugins = mgr.registry.search(args.query) if not plugins: print(f"No plugins matching '{args.query}'.") return for p in plugins: if args.verbose: print(_fmt_plugin_verbose(p)) print("-" * 72) else: print(_fmt_plugin_short(p)) print(f"\n{len(plugins)} match(es)") # ── info ───────────────────────────────────────────────────────────── elif args.command == "info": plugin = _resolve(mgr, args.plugin) if plugin is None: print(f"Plugin not found: {args.plugin}") sys.exit(1) print(_fmt_plugin_verbose(plugin)) # ── install-local ──────────────────────────────────────────────────── elif args.command == "install-local": fmt = PluginFormat(args.format) result = mgr.install_local(args.path, fmt) if result: print(f"Installed: {result.meta.name} ({result.meta.uri})") else: print("Installation failed.") sys.exit(1) # ── install-nam ────────────────────────────────────────────────────── elif args.command == "install-nam": result = mgr.nam_install_local(args.path, args.name) if result: print(f"Installed NAM model: {result.meta.name} ({result.nam_model_size})") if result.rpi4b_known_broken: print("⚠ Warning: This model may cause xruns on RPi4B (standard size).") else: print("Installation failed.") sys.exit(1) # ── download-nam ───────────────────────────────────────────────────── elif args.command == "download-nam": print(f"Downloading NAM model from {args.url}…") result = mgr.nam_download(args.url, args.name) if result: print(f"Downloaded: {result.meta.name} ({result.nam_model_size})") if result.rpi4b_known_broken: print("⚠ Warning: This model may cause xruns on RPi4B (standard size).") else: print("Download failed.") sys.exit(1) # ── nam-list ───────────────────────────────────────────────────────── elif args.command == "nam-list": models = mgr.nam.list_models() if not models: print("No NAM models installed.") return for m in models: compat = "✓ RPi4B" if m.rpi4b_compatible else "⚠ xruns likely" print(f" [{m.size_category:8}] {m.name:<30} {m.file_size_mb:6.1f} MB {compat}") counts = mgr.nam.count_models() print(f"\n nano:{counts['nano']} feather:{counts['feather']} " f"standard:{counts['standard']} custom:{counts['custom']}") # ── remove ─────────────────────────────────────────────────────────── elif args.command == "remove": plugin = _resolve(mgr, args.plugin) if plugin is None: print(f"Plugin not found: {args.plugin}") sys.exit(1) if mgr.remove(plugin.meta.uri, delete_files=args.files): action = "Removed (files deleted)" if args.files else "Removed (registry only)" print(f"{action}: {plugin.meta.name}") else: print("Remove failed.") sys.exit(1) # ── enable / disable ───────────────────────────────────────────────── elif args.command == "enable": plugin = _resolve(mgr, args.plugin) if plugin is None: print(f"Plugin not found: {args.plugin}") sys.exit(1) if mgr.enable_plugin(plugin.meta.uri): print(f"Enabled: {plugin.meta.name}") else: print("Failed.") sys.exit(1) elif args.command == "disable": plugin = _resolve(mgr, args.plugin) if plugin is None: print(f"Plugin not found: {args.plugin}") sys.exit(1) if mgr.disable_plugin(plugin.meta.uri): print(f"Disabled: {plugin.meta.name}") else: print("Failed.") sys.exit(1) # ── blacklist / unblacklist ────────────────────────────────────────── elif args.command == "blacklist": plugin = _resolve(mgr, args.plugin) if plugin is None: print(f"Plugin not found: {args.plugin}") sys.exit(1) if mgr.blacklist_plugin(plugin.meta.uri): print(f"Blacklisted: {plugin.meta.name}") else: print("Failed.") sys.exit(1) elif args.command == "unblacklist": plugin = _resolve(mgr, args.plugin) if plugin is None: print(f"Plugin not found: {args.plugin}") sys.exit(1) if mgr.unblacklist_plugin(plugin.meta.uri): print(f"Removed from blacklist: {plugin.meta.name}") else: print("Failed.") sys.exit(1) # ── blacklist-list ─────────────────────────────────────────────────── elif args.command == "blacklist-list": if args.builtin: entries = mgr.blacklist.list_builtin() elif args.user: entries = mgr.blacklist.list_user() else: entries = mgr.blacklist.list_all() for e in entries: tag = "blk" if e.severity == "block" else "warn" src = "builtin" if e.added_by == "builtin" else "user" print(f" [{tag}] [{src}] {e.field}:{e.pattern}") if e.reason: print(f" {e.reason}") print(f"\n{len(entries)} rule(s)") # ── blacklist-add ──────────────────────────────────────────────────── elif args.command == "blacklist-add": import time entry = BlacklistEntry( pattern=args.pattern, field=args.field, reason=args.reason, severity=args.severity, added_by="user", added_at=time.time(), ) mgr.blacklist.add_user_entry(entry) print(f"Added blacklist rule: {args.field}:{args.pattern} [{args.severity}]") # ── stats ──────────────────────────────────────────────────────────── elif args.command == "stats": stats = mgr.registry.stats() print(f"Plugin Registry Statistics") print(f" Total plugins: {stats['total']}") print(f" By format:") for fmt, count in stats["by_format"].items(): print(f" {fmt:<8} {count}") print(f" Blacklisted: {stats['blacklisted']}") print(f" Stale: {stats['stale']}") print(f" RPi4B verified: {stats['rpi4b_verified']}") print(f" NAM models: {stats['nam_models']}") # ── vacuum ─────────────────────────────────────────────────────────── elif args.command == "vacuum": print("Compacting database…") mgr.registry.vacuum() print("Done.") if __name__ == "__main__": main()