livepilot 1.23.2 → 1.23.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +108 -10
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/m4l_device/livepilot_bridge.js +39 -1
  5. package/mcp_server/__init__.py +1 -1
  6. package/mcp_server/atlas/cross_pack_chain.py +658 -0
  7. package/mcp_server/atlas/demo_story.py +700 -0
  8. package/mcp_server/atlas/extract_chain.py +786 -0
  9. package/mcp_server/atlas/macro_fingerprint.py +554 -0
  10. package/mcp_server/atlas/overlays.py +95 -3
  11. package/mcp_server/atlas/pack_aware_compose.py +1255 -0
  12. package/mcp_server/atlas/preset_resolver.py +238 -0
  13. package/mcp_server/atlas/tools.py +1001 -31
  14. package/mcp_server/atlas/transplant.py +1177 -0
  15. package/mcp_server/mix_engine/state_builder.py +44 -1
  16. package/mcp_server/runtime/capability_state.py +34 -3
  17. package/mcp_server/runtime/remote_commands.py +10 -0
  18. package/mcp_server/server.py +45 -24
  19. package/mcp_server/tools/agent_os.py +33 -9
  20. package/mcp_server/tools/analyzer.py +84 -23
  21. package/mcp_server/tools/browser.py +20 -1
  22. package/mcp_server/tools/devices.py +78 -11
  23. package/mcp_server/tools/perception.py +5 -1
  24. package/mcp_server/tools/tracks.py +39 -2
  25. package/mcp_server/user_corpus/__init__.py +48 -0
  26. package/mcp_server/user_corpus/manifest.py +142 -0
  27. package/mcp_server/user_corpus/plugin_engine/__init__.py +39 -0
  28. package/mcp_server/user_corpus/plugin_engine/detector.py +579 -0
  29. package/mcp_server/user_corpus/plugin_engine/manual.py +347 -0
  30. package/mcp_server/user_corpus/plugin_engine/research.py +247 -0
  31. package/mcp_server/user_corpus/runner.py +261 -0
  32. package/mcp_server/user_corpus/scanner.py +115 -0
  33. package/mcp_server/user_corpus/scanners/__init__.py +18 -0
  34. package/mcp_server/user_corpus/scanners/adg.py +79 -0
  35. package/mcp_server/user_corpus/scanners/als.py +144 -0
  36. package/mcp_server/user_corpus/scanners/amxd.py +374 -0
  37. package/mcp_server/user_corpus/scanners/plugin_preset.py +202 -0
  38. package/mcp_server/user_corpus/tools.py +904 -0
  39. package/mcp_server/user_corpus/wizard.py +224 -0
  40. package/package.json +2 -2
  41. package/remote_script/LivePilot/__init__.py +1 -1
  42. package/remote_script/LivePilot/browser.py +7 -2
  43. package/remote_script/LivePilot/devices.py +9 -0
  44. package/remote_script/LivePilot/simpler_sample.py +98 -0
  45. package/requirements.txt +3 -3
  46. package/server.json +2 -2
@@ -0,0 +1,579 @@
1
+ """Phase 2.1 + 2.2 — Detect installed plugins on disk + extract identity.
2
+
3
+ Walks the OS-specific plugin folders and produces an inventory entry per
4
+ plugin. Identity extraction:
5
+ - VST3: parse Contents/Resources/moduleinfo.json (mandatory per VST3 SDK 3.7+)
6
+ - AU v2: parse .component bundle's Contents/Info.plist
7
+ - AU v3: enumerate via `auval -a` (catches iOS-ported Mac Catalyst apps
8
+ whose .appex extensions live INSIDE app bundles at non-standard
9
+ paths like /Applications/IOS audio/<App>.app/...). The auval-based
10
+ scanner replaces the path-walker for AU on macOS — captures both
11
+ v2 and v3 in one pass with manufacturer + name resolved.
12
+ - VST2: read CcnK chunk header from the binary
13
+
14
+ Never raises on a malformed bundle — logs + skips. On macOS, plugin "files"
15
+ are actually directory bundles (`.vst3`, `.component`, `.vst`, `.aaxplugin`).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import logging
22
+ import platform
23
+ import plistlib
24
+ import re
25
+ import shutil
26
+ import struct
27
+ import subprocess
28
+ import sys
29
+ from dataclasses import dataclass, asdict
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ # ─── Default search paths per OS ────────────────────────────────────────────
37
+
38
+
39
+ def _macos_plugin_dirs() -> list[tuple[Path, str]]:
40
+ home = Path.home()
41
+ return [
42
+ (Path("/Library/Audio/Plug-Ins/VST3"), "VST3"),
43
+ (home / "Library/Audio/Plug-Ins/VST3", "VST3"),
44
+ (Path("/Library/Audio/Plug-Ins/Components"), "AU"),
45
+ (home / "Library/Audio/Plug-Ins/Components", "AU"),
46
+ (Path("/Library/Audio/Plug-Ins/VST"), "VST2"),
47
+ (home / "Library/Audio/Plug-Ins/VST", "VST2"),
48
+ (Path("/Library/Application Support/Avid/Audio/Plug-Ins"), "AAX"),
49
+ ]
50
+
51
+
52
+ def _windows_plugin_dirs() -> list[tuple[Path, str]]:
53
+ program_files = Path("C:/Program Files")
54
+ return [
55
+ (program_files / "Common Files/VST3", "VST3"),
56
+ (program_files / "VstPlugins", "VST2"),
57
+ (program_files / "Common Files/Avid/Audio/Plug-Ins", "AAX"),
58
+ ]
59
+
60
+
61
+ def _linux_plugin_dirs() -> list[tuple[Path, str]]:
62
+ home = Path.home()
63
+ return [
64
+ (Path("/usr/lib/vst3"), "VST3"),
65
+ (Path("/usr/local/lib/vst3"), "VST3"),
66
+ (home / ".vst3", "VST3"),
67
+ (Path("/usr/lib/lv2"), "LV2"),
68
+ (Path("/usr/local/lib/lv2"), "LV2"),
69
+ (home / ".lv2", "LV2"),
70
+ ]
71
+
72
+
73
+ def default_plugin_dir() -> list[tuple[Path, str]]:
74
+ """Return the default OS-appropriate plugin search paths.
75
+
76
+ Each entry is (Path, format_label). Caller can extend or override.
77
+ """
78
+ sysname = platform.system()
79
+ if sysname == "Darwin":
80
+ return _macos_plugin_dirs()
81
+ if sysname == "Windows":
82
+ return _windows_plugin_dirs()
83
+ return _linux_plugin_dirs()
84
+
85
+
86
+ # ─── Detected-plugin record ─────────────────────────────────────────────────
87
+
88
+
89
+ @dataclass
90
+ class DetectedPlugin:
91
+ plugin_id: str # stable slug: "<vendor-slug>-<plugin-slug>"
92
+ name: str
93
+ vendor: str | None
94
+ format: str # VST3 / AU / VST2 / AAX / LV2
95
+ version: str | None
96
+ bundle_path: str
97
+ unique_id: str | None # CID / AU subtype / VST plugin code
98
+ file_size_kb: int | None = None
99
+ sdk_metadata: dict[str, Any] | None = None # raw moduleinfo / Info.plist
100
+
101
+ def to_dict(self) -> dict:
102
+ return asdict(self)
103
+
104
+
105
+ # ─── Top-level entry ────────────────────────────────────────────────────────
106
+
107
+
108
+ def detect_installed_plugins(
109
+ paths: list[tuple[Path, str]] | None = None,
110
+ formats: list[str] | None = None,
111
+ use_auval: bool = True,
112
+ ) -> list[DetectedPlugin]:
113
+ """Walk plugin folders + enumerate AUs via auval. Return all detected plugins.
114
+
115
+ On macOS, this combines two strategies:
116
+ - Path-based scanning of /Library/Audio/Plug-Ins/{VST3,VST,Components}
117
+ and the AAX folder — catches v2 AUs and all VST/AAX bundles.
118
+ - `auval -a` enumeration — catches AUv3 plugins whose .appex extensions
119
+ live inside arbitrary .app bundles (iOS-ported Mac Catalyst apps,
120
+ custom subdirectories like /Applications/IOS audio/, etc.).
121
+
122
+ Both passes are deduplicated by (vendor, name, format).
123
+
124
+ Parameters
125
+ ----------
126
+ paths : explicit (Path, format) pairs to scan. Defaults to OS-appropriate.
127
+ formats : restrict to these formats (e.g. ["VST3", "AU"]). Default: all.
128
+ use_auval : when True (default) and on macOS, run `auval -a` as the AU
129
+ source of truth — much more reliable than path-walking. Set to
130
+ False to fall back to path-only scanning (mostly for tests
131
+ that don't want subprocess calls).
132
+ """
133
+ if paths is None:
134
+ paths = default_plugin_dir()
135
+ if formats:
136
+ paths = [(p, f) for p, f in paths if f in formats]
137
+
138
+ results: list[DetectedPlugin] = []
139
+ seen_keys: set[tuple[str, str, str]] = set() # (vendor_lc, name_lc, format)
140
+
141
+ # 1. Path-based scan — covers VST3 / VST2 / AAX / LV2 + co-located AU v2
142
+ for root, fmt in paths:
143
+ if not root.exists():
144
+ continue
145
+ try:
146
+ for entry in sorted(root.iterdir()):
147
+ plugin = _identify_plugin(entry, fmt)
148
+ if plugin:
149
+ key = ((plugin.vendor or "").lower(), plugin.name.lower(), plugin.format)
150
+ if key not in seen_keys:
151
+ seen_keys.add(key)
152
+ results.append(plugin)
153
+ except (PermissionError, OSError) as e:
154
+ logger.warning("Cannot read %s: %s", root, e)
155
+
156
+ # 2. auval-based AU enumeration — captures AUv3 + arbitrary-location AUs
157
+ want_au = formats is None or "AU" in (formats or [])
158
+ if use_auval and want_au and platform.system() == "Darwin":
159
+ for plugin in _detect_via_auval():
160
+ key = ((plugin.vendor or "").lower(), plugin.name.lower(), plugin.format)
161
+ if key not in seen_keys:
162
+ seen_keys.add(key)
163
+ results.append(plugin)
164
+
165
+ return results
166
+
167
+
168
+ def _detect_via_auval() -> list[DetectedPlugin]:
169
+ """Run macOS's `auval -a` and parse its output into DetectedPlugin records.
170
+
171
+ auval is the system-shipped Audio Unit validator. `-a` enumerates every
172
+ AU registered with the system (both v2 and v3, including those packaged
173
+ as app extensions inside arbitrary .app bundles). This is the only
174
+ reliable way to find iOS-ported Mac Catalyst plugins that don't live
175
+ in /Library/Audio/Plug-Ins/Components/.
176
+
177
+ Output line format:
178
+ <typecode> <subtype> <manufacturer> - <vendor>: <plugin name>
179
+
180
+ Where:
181
+ typecode 'aufx'/'aumu'/'aumi' (effect/instrument/midi)
182
+ subtype 4-char AU subtype code
183
+ manufacturer 4-char manufacturer code (e.g. "Moog", "Chow")
184
+ vendor human-readable vendor string
185
+ plugin name human-readable plugin name
186
+
187
+ Returns empty list on any failure (auval missing, subprocess error,
188
+ parse miss). Never raises.
189
+ """
190
+ auval_bin = shutil.which("auval")
191
+ if not auval_bin:
192
+ return []
193
+ try:
194
+ proc = subprocess.run(
195
+ [auval_bin, "-a"],
196
+ capture_output=True, text=True, timeout=120,
197
+ check=False,
198
+ )
199
+ except (subprocess.SubprocessError, OSError) as e:
200
+ logger.warning("auval -a failed: %s", e)
201
+ return []
202
+
203
+ plugins: list[DetectedPlugin] = []
204
+ line_re = re.compile(
205
+ r"^(?P<typecode>aufx|aumu|aumi)\s+"
206
+ r"(?P<subtype>\S{4})\s+"
207
+ r"(?P<manuf>\S{4})\s*-\s*"
208
+ r"(?P<vendor>[^:]+):\s*"
209
+ r"(?P<name>.+?)$",
210
+ )
211
+ for raw_line in proc.stdout.splitlines():
212
+ line = raw_line.strip()
213
+ if not line:
214
+ continue
215
+ m = line_re.match(line)
216
+ if not m:
217
+ continue
218
+ typecode = m.group("typecode")
219
+ subtype = m.group("subtype")
220
+ manuf_code = m.group("manuf")
221
+ vendor = m.group("vendor").strip()
222
+ name = m.group("name").strip()
223
+
224
+ # Skip "*deprecated" markers — Apple keeps legacy version registered
225
+ # alongside the current one. The current entry comes through too.
226
+ if "*deprecated" in name.lower():
227
+ continue
228
+ # Skip Apple's system AUs — they're system utility (AUDelay, AUFilter,
229
+ # AUDistortion, etc.) that the user never thinks of as third-party
230
+ # plugins. Add them back if you want comprehensive system-AU coverage.
231
+ if vendor.lower() == "apple" and manuf_code == "appl":
232
+ continue
233
+
234
+ # Determine format via category heuristic. Most non-Apple AUs registered
235
+ # on modern macOS are AUv3 (the only path forward since 10.13). True v2
236
+ # plugins ALSO show up in this list because v2 is a registered AU type.
237
+ # We can't distinguish v2 from v3 without inspecting the plugin's
238
+ # executable architecture — so we tag all as "AU" and let downstream
239
+ # consumers infer specifics from bundle_path when available.
240
+ au_kind = {
241
+ "aufx": "audio_effect",
242
+ "aumu": "instrument",
243
+ "aumi": "midi_effect",
244
+ }.get(typecode, "unknown")
245
+
246
+ plugin_id = _slug(f"{vendor}-{name}")
247
+ unique_id = f"{typecode}.{subtype}.{manuf_code}"
248
+ plugins.append(DetectedPlugin(
249
+ plugin_id=plugin_id,
250
+ name=name,
251
+ vendor=vendor,
252
+ format="AU",
253
+ version=None, # auval doesn't surface version
254
+ bundle_path="", # not available from auval; resolve later if needed
255
+ unique_id=unique_id,
256
+ file_size_kb=None,
257
+ sdk_metadata={
258
+ "auval_typecode": typecode,
259
+ "auval_subtype": subtype,
260
+ "auval_manufacturer_code": manuf_code,
261
+ "au_role": au_kind, # audio_effect / instrument / midi_effect
262
+ },
263
+ ))
264
+ return plugins
265
+
266
+
267
+ def _identify_plugin(path: Path, fmt: str) -> DetectedPlugin | None:
268
+ """Dispatch to the format-specific identity extractor."""
269
+ if not path.exists():
270
+ return None
271
+ try:
272
+ if fmt == "VST3" and path.suffix == ".vst3":
273
+ return _identify_vst3(path)
274
+ if fmt == "AU" and path.suffix == ".component":
275
+ return _identify_au(path)
276
+ if fmt == "VST2" and (path.suffix == ".vst" or path.suffix == ".dylib"):
277
+ return _identify_vst2(path)
278
+ if fmt == "AAX" and path.suffix == ".aaxplugin":
279
+ return _identify_aax(path)
280
+ if fmt == "LV2" and path.is_dir():
281
+ return _identify_lv2(path)
282
+ except Exception as e: # noqa: BLE001 — never abort over one bad bundle
283
+ logger.warning("Identity extraction failed for %s: %s", path, e)
284
+ # If a format-specific parse failed, still emit a fallback record so the
285
+ # plugin shows up in the inventory with whatever we can derive from the path
286
+ return _fallback_identity(path, fmt)
287
+
288
+
289
+ # ─── VST3 ───────────────────────────────────────────────────────────────────
290
+
291
+
292
+ def _identify_vst3(bundle: Path) -> DetectedPlugin | None:
293
+ info_path = bundle / "Contents" / "Resources" / "moduleinfo.json"
294
+ plist_path = bundle / "Contents" / "Info.plist"
295
+ name = bundle.stem
296
+ vendor: str | None = None
297
+ version: str | None = None
298
+ unique_id: str | None = None
299
+ sdk: dict[str, Any] | None = None
300
+ if info_path.exists():
301
+ try:
302
+ sdk = json.loads(info_path.read_text(encoding="utf-8"))
303
+ if isinstance(sdk, dict):
304
+ name = sdk.get("Name") or name
305
+ vendor = sdk.get("Vendor")
306
+ version = sdk.get("Version")
307
+ classes = sdk.get("Classes") or []
308
+ if classes and isinstance(classes[0], dict):
309
+ unique_id = classes[0].get("CID")
310
+ except (json.JSONDecodeError, OSError) as e:
311
+ logger.debug("VST3 moduleinfo.json unreadable for %s: %s", bundle, e)
312
+ # Pre-3.7 VST3 bundles don't have moduleinfo.json; fall back to Info.plist
313
+ if not vendor and plist_path.exists():
314
+ try:
315
+ with plist_path.open("rb") as fh:
316
+ plist = plistlib.load(fh)
317
+ bundle_id = plist.get("CFBundleIdentifier") or ""
318
+ copyright_text = plist.get("NSHumanReadableCopyright") or ""
319
+ if not version:
320
+ version = plist.get("CFBundleShortVersionString")
321
+ if bundle_id:
322
+ parts = bundle_id.split(".")
323
+ if len(parts) >= 2 and parts[0].lower() in ("com", "net", "org", "io", "co"):
324
+ vendor = parts[1].title()
325
+ if not vendor and copyright_text:
326
+ m = re.search(r"\b(?:19|20)\d{2}\s+(?:by\s+)?([A-Za-z][\w&.\- ]{1,40})", copyright_text)
327
+ if m:
328
+ vendor = m.group(1).strip().rstrip(".,;:")
329
+ if sdk is None:
330
+ sdk = {}
331
+ sdk.setdefault("bundle_identifier", bundle_id)
332
+ except (plistlib.InvalidFileException, OSError) as e:
333
+ logger.debug("VST3 Info.plist unreadable for %s: %s", bundle, e)
334
+ plugin_id = _slug(f"{vendor or 'unknown'}-{name}")
335
+ return DetectedPlugin(
336
+ plugin_id=plugin_id, name=name, vendor=vendor, format="VST3",
337
+ version=version, bundle_path=str(bundle), unique_id=unique_id,
338
+ file_size_kb=_bundle_size_kb(bundle),
339
+ sdk_metadata=sdk,
340
+ )
341
+
342
+
343
+ # ─── AU ─────────────────────────────────────────────────────────────────────
344
+
345
+
346
+ def _identify_au(bundle: Path) -> DetectedPlugin | None:
347
+ info_path = bundle / "Contents" / "Info.plist"
348
+ bundle_name = bundle.stem
349
+ name = bundle_name
350
+ vendor: str | None = None
351
+ version: str | None = None
352
+ unique_id: str | None = None
353
+ sdk: dict[str, Any] | None = None
354
+ manufacturer_code: str | None = None
355
+ if info_path.exists():
356
+ try:
357
+ with info_path.open("rb") as fh:
358
+ plist = plistlib.load(fh)
359
+ if isinstance(plist, dict):
360
+ sdk = _strip_unjsonable(plist)
361
+ version = plist.get("CFBundleShortVersionString")
362
+ bundle_id = plist.get("CFBundleIdentifier") or ""
363
+ copyright_text = plist.get("NSHumanReadableCopyright") or ""
364
+
365
+ comps = plist.get("AudioComponents") or []
366
+ comp_name_raw: str = ""
367
+ if comps and isinstance(comps[0], dict):
368
+ c0 = comps[0]
369
+ comp_name_raw = (c0.get("name") or "")
370
+ manufacturer_code = _decode_au_id(c0.get("manufacturer"))
371
+ unique_id = _decode_au_id(c0.get("subtype"))
372
+
373
+ # Vendor + plugin name resolution — priority order:
374
+ # 1. AudioComponents.name "Vendor: Plugin" → split
375
+ # 2. Reverse-DNS from CFBundleIdentifier (com.<vendor>.<plugin>)
376
+ # 3. Plain prose copyright string
377
+ # 4. Fall back to 4-char manufacturer code as last resort
378
+ if comp_name_raw and ":" in comp_name_raw:
379
+ v, n = (s.strip() for s in comp_name_raw.split(":", 1))
380
+ if v:
381
+ vendor = v
382
+ if n:
383
+ name = n
384
+ if not name and comp_name_raw:
385
+ name = comp_name_raw
386
+ if not name:
387
+ name = plist.get("CFBundleName") or bundle_name
388
+
389
+ if not vendor and bundle_id:
390
+ parts = bundle_id.split(".")
391
+ if len(parts) >= 2 and parts[0].lower() in ("com", "net", "org", "io", "co"):
392
+ vendor = parts[1].title() # com.spectrasonics.X → "Spectrasonics"
393
+ if not vendor and copyright_text:
394
+ # "Copyright © 2024 u-he" → "u-he"; best-effort word grab after the year
395
+ m = re.search(r"\b(?:19|20)\d{2}\s+(?:by\s+)?([A-Za-z][\w&.\- ]{1,40})", copyright_text)
396
+ if m:
397
+ vendor = m.group(1).strip().rstrip(".,;:")
398
+ if not vendor:
399
+ vendor = manufacturer_code # last resort, the 4-char code
400
+
401
+ # Stash both readable + code in sdk metadata for downstream debugging
402
+ if sdk is None:
403
+ sdk = {}
404
+ sdk.setdefault("manufacturer_code", manufacturer_code)
405
+ sdk.setdefault("bundle_identifier", bundle_id)
406
+ except (plistlib.InvalidFileException, OSError) as e:
407
+ logger.debug("AU Info.plist unreadable for %s: %s", bundle, e)
408
+ plugin_id = _slug(f"{vendor or 'unknown'}-{name}")
409
+ return DetectedPlugin(
410
+ plugin_id=plugin_id, name=name, vendor=vendor, format="AU",
411
+ version=version, bundle_path=str(bundle), unique_id=unique_id,
412
+ file_size_kb=_bundle_size_kb(bundle),
413
+ sdk_metadata=sdk,
414
+ )
415
+
416
+
417
+ # ─── VST2 ───────────────────────────────────────────────────────────────────
418
+
419
+
420
+ def _identify_vst2(path: Path) -> DetectedPlugin | None:
421
+ # On macOS .vst is a bundle; on Windows/Linux it can be a single .dll/.so/.dylib
422
+ binary_path = path
423
+ if path.is_dir():
424
+ # macOS bundle — look inside Contents/MacOS/
425
+ macos_dir = path / "Contents" / "MacOS"
426
+ if macos_dir.exists():
427
+ execs = list(macos_dir.iterdir())
428
+ if execs:
429
+ binary_path = execs[0]
430
+ name = path.stem
431
+ plugin_code: str | None = None
432
+ program_name: str | None = None
433
+ try:
434
+ with binary_path.open("rb") as fh:
435
+ head = fh.read(60)
436
+ if head[:4] == b"CcnK" and len(head) >= 60:
437
+ plugin_code = head[16:20].decode("ascii", errors="ignore").strip()
438
+ name_bytes = head[28:60].split(b"\x00", 1)[0]
439
+ program_name = name_bytes.decode("latin-1", errors="ignore").strip()
440
+ except OSError:
441
+ pass
442
+ # VST2 doesn't store vendor in the binary — infer from path
443
+ vendor = path.parent.name if path.parent.name not in ("VST", "VstPlugins") else None
444
+ plugin_id = _slug(f"{vendor or 'unknown'}-{name}")
445
+ return DetectedPlugin(
446
+ plugin_id=plugin_id, name=name, vendor=vendor, format="VST2",
447
+ version=None, bundle_path=str(path), unique_id=plugin_code,
448
+ file_size_kb=_bundle_size_kb(path),
449
+ sdk_metadata={"program_name": program_name} if program_name else None,
450
+ )
451
+
452
+
453
+ # ─── AAX ────────────────────────────────────────────────────────────────────
454
+
455
+
456
+ def _identify_aax(bundle: Path) -> DetectedPlugin | None:
457
+ manifest = bundle / "Contents" / "Resources" / "PluginManifest.plist"
458
+ name = bundle.stem
459
+ vendor: str | None = None
460
+ version: str | None = None
461
+ unique_id: str | None = None
462
+ sdk: dict[str, Any] | None = None
463
+ if manifest.exists():
464
+ try:
465
+ with manifest.open("rb") as fh:
466
+ plist = plistlib.load(fh)
467
+ if isinstance(plist, dict):
468
+ sdk = _strip_unjsonable(plist)
469
+ name = plist.get("PluginName") or plist.get("CFBundleName") or name
470
+ vendor = plist.get("ManufacturerName") or plist.get("Manufacturer")
471
+ version = plist.get("PluginVersion") or plist.get("CFBundleShortVersionString")
472
+ unique_id = plist.get("PluginID")
473
+ except (plistlib.InvalidFileException, OSError) as e:
474
+ logger.debug("AAX manifest unreadable for %s: %s", bundle, e)
475
+ plugin_id = _slug(f"{vendor or 'unknown'}-{name}")
476
+ return DetectedPlugin(
477
+ plugin_id=plugin_id, name=name, vendor=vendor, format="AAX",
478
+ version=version, bundle_path=str(bundle), unique_id=unique_id,
479
+ file_size_kb=_bundle_size_kb(bundle),
480
+ sdk_metadata=sdk,
481
+ )
482
+
483
+
484
+ # ─── LV2 (Linux) ────────────────────────────────────────────────────────────
485
+
486
+
487
+ def _identify_lv2(plugin_dir: Path) -> DetectedPlugin | None:
488
+ """LV2 plugins are RDF-described in .ttl files. Best-effort name + uri."""
489
+ name = plugin_dir.name
490
+ uri: str | None = None
491
+ vendor: str | None = None
492
+ for ttl in plugin_dir.glob("*.ttl"):
493
+ try:
494
+ text = ttl.read_text(encoding="utf-8", errors="ignore")
495
+ except OSError:
496
+ continue
497
+ m = re.search(r'<([^>]+)>\s+a\s+lv2:Plugin', text)
498
+ if m:
499
+ uri = m.group(1)
500
+ m = re.search(r'doap:name\s+"([^"]+)"', text)
501
+ if m:
502
+ name = m.group(1)
503
+ m = re.search(r'doap:maintainer[^"]*"([^"]+)"', text)
504
+ if m:
505
+ vendor = m.group(1)
506
+ if uri:
507
+ break
508
+ plugin_id = _slug(f"{vendor or 'unknown'}-{name}")
509
+ return DetectedPlugin(
510
+ plugin_id=plugin_id, name=name, vendor=vendor, format="LV2",
511
+ version=None, bundle_path=str(plugin_dir), unique_id=uri,
512
+ file_size_kb=_bundle_size_kb(plugin_dir),
513
+ sdk_metadata=None,
514
+ )
515
+
516
+
517
+ # ─── Fallback ───────────────────────────────────────────────────────────────
518
+
519
+
520
+ def _fallback_identity(path: Path, fmt: str) -> DetectedPlugin:
521
+ name = path.stem
522
+ return DetectedPlugin(
523
+ plugin_id=_slug(f"unknown-{name}"),
524
+ name=name, vendor=None, format=fmt,
525
+ version=None, bundle_path=str(path), unique_id=None,
526
+ file_size_kb=_bundle_size_kb(path),
527
+ sdk_metadata=None,
528
+ )
529
+
530
+
531
+ # ─── Helpers ────────────────────────────────────────────────────────────────
532
+
533
+
534
+ def _slug(s: str) -> str:
535
+ return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") or "unknown"
536
+
537
+
538
+ def _decode_au_id(value: Any) -> str | None:
539
+ """AU 4-char codes are 32-bit big-endian ints. Decode to ASCII."""
540
+ if value is None:
541
+ return None
542
+ if isinstance(value, str):
543
+ return value
544
+ if isinstance(value, int):
545
+ try:
546
+ return struct.pack(">I", value).decode("ascii", errors="ignore").strip()
547
+ except (ValueError, struct.error):
548
+ return str(value)
549
+ return str(value)
550
+
551
+
552
+ def _strip_unjsonable(d: Any) -> Any:
553
+ """Recursively strip non-JSON-serializable values (bytes → hex, datetime → str)."""
554
+ if isinstance(d, dict):
555
+ return {k: _strip_unjsonable(v) for k, v in d.items()}
556
+ if isinstance(d, list):
557
+ return [_strip_unjsonable(v) for v in d]
558
+ if isinstance(d, bytes):
559
+ return d.hex()
560
+ if isinstance(d, (str, int, float, bool)) or d is None:
561
+ return d
562
+ return str(d)
563
+
564
+
565
+ def _bundle_size_kb(path: Path) -> int | None:
566
+ """Total size of a plugin bundle / file in KB. None if unreadable."""
567
+ try:
568
+ if path.is_file():
569
+ return int(path.stat().st_size / 1024)
570
+ total = 0
571
+ for p in path.rglob("*"):
572
+ try:
573
+ if p.is_file():
574
+ total += p.stat().st_size
575
+ except OSError:
576
+ pass
577
+ return int(total / 1024)
578
+ except OSError:
579
+ return None