Phase 1-4: Audio stack, mixer engine, MIDI, and network API
Lint & Validate / lint (push) Has been cancelled
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)
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user