9cd8292acc
Lint & Validate / lint (push) Has been cancelled
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)
442 lines
16 KiB
Python
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")
|