Files
raspberry-pi-mixer/src/plugin/manager.py
T
shawn 9cd8292acc
Lint & Validate / lint (push) Has been cancelled
Phase 1-4: Audio stack, mixer engine, MIDI, and network API
P2-R1: ALSA + JACK2 low-latency config (scripts, quirks, tuning)
P2-R2: Carla integration (build scripts, 8ch rack config, NAM LV2 support)
P2-R3: Plugin manager, categories, blacklist, NAM model support
P3-R1: Mixer DSP engine (channel strip, routing matrix, bus mgr, automation)
P4-R1: MIDI engine (learn mode, clock sync, HID discovery, mapping store)
P4-R2: Network API (OSC server, FastAPI REST, WebSocket, auth, rate limiter)
P5-R1: Touchscreen UI evaluation + main entry point
docs: Audio stack, Carla integration, MIDI support, UI evaluation
tests: Full test suite (292 passing)
2026-05-19 20:39:17 -04:00

442 lines
16 KiB
Python

"""Plugin manager — install, remove, update plugin bundles.
Coordinates between the scanner, registry, blacklist, and category
classifier to provide a unified plugin lifecycle API.
"""
from __future__ import annotations
import logging
import shutil
import subprocess
import tarfile
import tempfile
import time
import zipfile
from pathlib import Path
from typing import Optional
from .blacklist import PluginBlacklist
from .categories import enrich_plugin
from .nam import NAMManager, NAMModelMeta
from .registry import PluginRegistry
from .scanner import scan_all, scan_format
from .types import (
PluginBundle,
PluginCategory,
PluginFormat,
PluginInfo,
PluginStatus,
)
logger = logging.getLogger(__name__)
class PluginManager:
"""Top-level plugin lifecycle manager.
Provides a unified interface for:
- Scanning filesystem for plugins
- Registering plugins in the SQLite database
- Installing/removing/updating plugin bundles
- Blacklist checking
- Category enrichment
- NAM model management
"""
def __init__(
self,
registry: PluginRegistry | None = None,
blacklist: PluginBlacklist | None = None,
nam_manager: NAMManager | None = None,
):
self.registry = registry or PluginRegistry()
self.blacklist = blacklist or PluginBlacklist()
self.nam = nam_manager or NAMManager()
# ── Scan & Sync ─────────────────────────────────────────────────────────
def scan(self) -> list[PluginInfo]:
"""Run a full filesystem scan and return discovered plugins."""
plugins = scan_all()
# Enrich categories
for plugin in plugins:
enrich_plugin(plugin)
# Apply blacklist
for plugin in plugins:
if self.blacklist.is_blocked(
plugin.meta.uri, plugin.meta.name, plugin.bundle_path
):
plugin.status = PluginStatus.BLACKLISTED
# Apply size-based NAM filtering
for plugin in plugins:
if plugin.meta.format == PluginFormat.NAM:
if plugin.nam_model_size == "standard":
plugin.status = PluginStatus.DISABLED
plugin.error_message = (
"Standard NAM models are known to cause xruns on RPi4B. "
"Use nano or feather models instead."
)
return plugins
def sync(self) -> dict:
"""Scan and synchronize the registry.
Returns the sync result dict: {inserted, updated, stale}.
"""
plugins = self.scan()
result = self.registry.sync_from_scan(plugins)
return result
# ── Install ─────────────────────────────────────────────────────────────
def install_bundle(self, bundle: PluginBundle) -> PluginInfo | None:
"""Install a plugin from a bundle descriptor.
Downloads the bundle, extracts it, optionally runs a build script,
and registers the resulting plugin.
Returns the PluginInfo if successful, None on failure.
"""
logger.info("Installing plugin bundle: %s %s", bundle.name, bundle.version)
# Determine install path based on format
format_install_dirs = {
PluginFormat.LV2: Path("/usr/local/lib/lv2"),
PluginFormat.VST3: Path("/usr/local/lib/vst3"),
PluginFormat.LADSPA: Path("/usr/local/lib/ladspa"),
PluginFormat.VST2: Path("/usr/local/lib/vst"),
}
install_dir = format_install_dirs.get(bundle.format, Path.home() / ".lv2")
with tempfile.TemporaryDirectory(prefix="rpi-mixer-install-") as tmpdir:
tmp = Path(tmpdir)
archive_path = tmp / f"{bundle.name}.{bundle.source_type}"
# Download
try:
self._download_file(bundle.source_url, archive_path)
except Exception as e:
logger.error("Download failed for %s: %s", bundle.name, e)
return None
# Verify checksum
if bundle.checksum_sha256:
import hashlib
actual = hashlib.sha256(archive_path.read_bytes()).hexdigest()
if actual.lower() != bundle.checksum_sha256.lower():
logger.error("Checksum mismatch for %s", bundle.name)
return None
# Extract
extract_dir = tmp / "extracted"
extract_dir.mkdir()
try:
self._extract(archive_path, extract_dir)
except Exception as e:
logger.error("Extraction failed for %s: %s", bundle.name, e)
return None
# Run build script if specified
if bundle.build_script:
build_script_path = extract_dir / bundle.build_script
if build_script_path.exists():
try:
subprocess.run(
["bash", str(build_script_path)],
cwd=str(extract_dir),
check=True,
capture_output=True,
text=True,
timeout=600,
)
except subprocess.CalledProcessError as e:
logger.error("Build failed for %s: %s\n%s", bundle.name, e, e.stderr)
return None
# Copy files to install destination
install_dir.mkdir(parents=True, exist_ok=True)
if bundle.install_paths:
for src_rel, dest_rel in bundle.install_paths.items():
src = extract_dir / src_rel
dest = install_dir / dest_rel
if src.is_dir():
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(src, dest)
else:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
else:
# Default: copy everything to install dir
bundle_name = extract_dir.name
dest = install_dir / bundle_name
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(extract_dir, dest)
# After install, re-scan to pick up the new plugin
plugins = scan_format(bundle.format)
for plugin in plugins:
if plugin.meta.name.lower() == bundle.name.lower():
enrich_plugin(plugin)
self.registry.upsert(plugin)
logger.info("Installed and registered: %s", plugin.meta.name)
return plugin
logger.warning("Plugin %s installed but not detected by scan", bundle.name)
return None
def install_local(self, source_path: str | Path, format: PluginFormat) -> PluginInfo | None:
"""Install a locally-available plugin by copying it to the appropriate directory."""
src = Path(source_path)
if not src.exists():
logger.error("Source not found: %s", src)
return None
format_install_dirs = {
PluginFormat.LV2: Path("/usr/local/lib/lv2"),
PluginFormat.VST3: Path("/usr/local/lib/vst3"),
PluginFormat.LADSPA: Path("/usr/local/lib/ladspa"),
}
install_dir = format_install_dirs.get(
format, Path.home() / f".{format.value}"
)
install_dir.mkdir(parents=True, exist_ok=True)
if src.is_dir():
dest = install_dir / src.name
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(src, dest)
else:
dest = install_dir / src.name
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
logger.info("Installed local plugin to %s", dest)
# Re-scan
plugins = scan_format(format)
for plugin in plugins:
if str(dest) in plugin.bundle_path or plugin.bundle_path == str(dest):
enrich_plugin(plugin)
self.registry.upsert(plugin)
return plugin
return None
# ── Remove ──────────────────────────────────────────────────────────────
def remove(self, uri: str, delete_files: bool = False) -> bool:
"""Remove a plugin from the registry.
If delete_files is True, also removes the plugin files from disk.
Returns True if the plugin was found and removed.
"""
plugin = self.registry.get(uri)
if plugin is None:
logger.warning("Plugin not found in registry: %s", uri)
return False
if delete_files and plugin.bundle_path:
bundle = Path(plugin.bundle_path)
if bundle.exists():
try:
if bundle.is_dir():
shutil.rmtree(bundle)
else:
bundle.unlink()
logger.info("Deleted plugin files: %s", bundle)
except OSError as e:
logger.error("Failed to delete plugin files: %s", e)
return self.registry.delete(uri)
def remove_by_name(self, name: str, delete_files: bool = False) -> bool:
"""Remove a plugin by name."""
plugin = self.registry.get_by_name(name)
if plugin is None:
logger.warning("Plugin not found: %s", name)
return False
return self.remove(plugin.meta.uri, delete_files)
# ── Update ──────────────────────────────────────────────────────────────
def update(self, uri: str) -> PluginInfo | None:
"""Refresh a plugin's metadata by re-scanning it.
Returns the updated PluginInfo, or None if the plugin is gone.
"""
plugin = self.registry.get(uri)
if plugin is None:
logger.warning("Plugin not found: %s", uri)
return None
# Re-scan the format
plugins = scan_format(plugin.meta.format)
for scanned in plugins:
if scanned.meta.uri == uri:
enrich_plugin(scanned)
scanned.installed_at = plugin.installed_at
scanned.updated_at = time.time()
# Re-check blacklist
if self.blacklist.is_blocked(
scanned.meta.uri, scanned.meta.name, scanned.bundle_path
):
scanned.status = PluginStatus.BLACKLISTED
self.registry.upsert(scanned)
return scanned
# Plugin not found by re-scan → mark stale
self.registry.update(uri, status=PluginStatus.STALE.value)
logger.info("Plugin %s not found on re-scan, marked stale", uri)
return None
def update_all(self) -> dict:
"""Re-scan all installed plugins to refresh metadata.
Returns {updated, stale_uris}.
"""
updated_count = 0
stale_uris: list[str] = []
for plugin in self.registry.list_all():
result = self.update(plugin.meta.uri)
if result:
updated_count += 1
else:
stale_uris.append(plugin.meta.uri)
return {"updated": updated_count, "stale": stale_uris}
# ── Blacklist management ────────────────────────────────────────────────
def blacklist_plugin(self, uri: str) -> bool:
"""Mark a plugin as blacklisted."""
return self.registry.update(uri, status=PluginStatus.BLACKLISTED.value)
def unblacklist_plugin(self, uri: str) -> bool:
"""Restore a blacklisted plugin to active status."""
return self.registry.update(uri, status=PluginStatus.ACTIVE.value)
def enable_plugin(self, uri: str) -> bool:
"""Enable a disabled plugin."""
return self.registry.update(uri, status=PluginStatus.ACTIVE.value)
def disable_plugin(self, uri: str) -> bool:
"""Disable a plugin without removing it."""
return self.registry.update(uri, status=PluginStatus.DISABLED.value)
# ── NAM model management ────────────────────────────────────────────────
def nam_download(self, url: str, name: str | None = None) -> PluginInfo | None:
"""Download a NAM model and register it."""
path = self.nam.download(url, name)
if path is None:
return None
meta = self.nam.get_model(path.stem)
if meta is None:
return None
info = self.nam.to_plugin_info(meta)
enrich_plugin(info)
self.registry.upsert(info)
return info
def nam_install_local(self, source: str, name: str | None = None) -> PluginInfo | None:
"""Install a local .nam file and register it."""
path = self.nam.install_local(source, name)
if path is None:
return None
meta = self.nam.get_model(path.stem)
if meta is None:
return None
info = self.nam.to_plugin_info(meta)
enrich_plugin(info)
self.registry.upsert(info)
return info
def nam_remove(self, name: str) -> bool:
"""Remove a NAM model by name."""
# Remove from registry
model_path = self.nam._model_path(name)
sha = __import__("hashlib").sha256(str(model_path).encode()).hexdigest()[:16]
uri = f"urn:nam:{name}:{sha}"
self.registry.delete(uri)
# Remove from disk
return self.nam.remove(name)
def nam_list(self) -> list[PluginInfo]:
"""List all installed NAM models as PluginInfo objects."""
return self.registry.list_nam_models()
# ── Query helpers ───────────────────────────────────────────────────────
def list_plugins(
self,
format: PluginFormat | None = None,
category: PluginCategory | None = None,
loadable_only: bool = False,
) -> list[PluginInfo]:
"""Query plugins with optional filters."""
if format:
plugins = self.registry.list_by_format(format)
elif category:
plugins = self.registry.list_by_category(category)
else:
plugins = self.registry.list_all()
if loadable_only:
plugins = [p for p in plugins if p.is_loadable]
return plugins
def search(self, query: str) -> list[PluginInfo]:
"""Search plugins by name, description, or author."""
return self.registry.search(query)
# ── Internal helpers ────────────────────────────────────────────────────
@staticmethod
def _download_file(url: str, dest: Path) -> None:
"""Download a file from a URL to a local path."""
from urllib.request import urlopen, Request
req = Request(url, headers={"User-Agent": "rpi-mixer-plugin-mgr/1.0"})
with urlopen(req, timeout=300) as response:
dest.write_bytes(response.read())
@staticmethod
def _extract(archive_path: Path, dest_dir: Path) -> None:
"""Extract an archive (tar.gz, tar.bz2, tar.xz, zip) to a directory."""
fname = archive_path.name.lower()
if fname.endswith((".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar")):
with tarfile.open(archive_path) as tar:
tar.extractall(path=dest_dir)
elif fname.endswith(".zip"):
with zipfile.ZipFile(archive_path) as zf:
zf.extractall(dest_dir)
else:
# Try tar first, then zip
try:
with tarfile.open(archive_path) as tar:
tar.extractall(path=dest_dir)
except tarfile.ReadError:
try:
with zipfile.ZipFile(archive_path) as zf:
zf.extractall(dest_dir)
except zipfile.BadZipFile:
raise ValueError(f"Cannot extract {archive_path}: unknown format")