Diagnosing capture on a real TV meant reading pactlSources off the screen and typing an exact PulseAudio source name back in through the same remote-driven text field — no way to copy-paste, easy to mistype, and the one piece of information (which source, if any, is actually RUNNING) was buried in a JSON dump. Added two choice() pickers bound to the same capture.device setting: one built from pactlSources (pulse/auto backends), one built from alsaCapturePcms (alsa backend), both parsed from diagnostics the service already collects — no new Luna method needed. Diagnostics already run once at boot, so the picker is populated immediately, before the user ever presses "Run diagnostics" by hand. Picking a value writes straight into capture.device, and the plain text field stays as the fallback for anything the parser misses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
109 lines
4.5 KiB
Python
Executable File
109 lines
4.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Checks unraid/lgtv-audiocap-loopback.plg without needing an Unraid box.
|
|
|
|
Verifies the plugin is well-formed XML (a CDATA-free bash script anywhere in
|
|
it means a stray "&" or "<" one edit away from breaking the DOCTYPE entity
|
|
expansion Unraid's installer relies on), that entities substitute the way
|
|
Unraid's installer would substitute them, that both embedded scripts are
|
|
syntactically valid bash, and that the install/remove pair is idempotent and
|
|
symmetric against a scratch go-file.
|
|
"""
|
|
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import os
|
|
import xml.dom.minidom as minidom
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
PLG = os.path.join(HERE, os.pardir, "unraid", "lgtv-audiocap-loopback.plg")
|
|
|
|
passed = 0
|
|
failed = 0
|
|
|
|
|
|
def check(condition, description):
|
|
global passed, failed
|
|
if condition:
|
|
print(" ok %s" % description)
|
|
passed += 1
|
|
else:
|
|
print(" FAIL %s" % description)
|
|
failed += 1
|
|
|
|
|
|
def bash_syntax_ok(script):
|
|
result = subprocess.run(["bash", "-n"], input=script, text=True,
|
|
capture_output=True)
|
|
return result.returncode == 0, result.stderr
|
|
|
|
|
|
def main():
|
|
doc = minidom.parse(PLG)
|
|
|
|
plugin = doc.getElementsByTagName("PLUGIN")
|
|
check(len(plugin) == 1, "exactly one PLUGIN element")
|
|
attrs = dict(plugin[0].attributes.items()) if plugin else {}
|
|
for key in ("name", "author", "version", "pluginURL", "min"):
|
|
check(bool(attrs.get(key)), "PLUGIN has a non-empty %s attribute" % key)
|
|
check(attrs.get("name") == "lgtv-audiocap-loopback", "name matches the filename's stem")
|
|
check(attrs.get("pluginURL", "").endswith(attrs.get("name", "\0") + ".plg"),
|
|
"pluginURL points at this same file's name")
|
|
|
|
files = doc.getElementsByTagName("FILE")
|
|
check(len(files) == 2, "exactly two FILE blocks (install + remove)")
|
|
|
|
install_script = remove_script = None
|
|
for f in files:
|
|
inline = f.getElementsByTagName("INLINE")
|
|
check(len(inline) == 1, "FILE (Method=%s) has one INLINE child" % (f.getAttribute("Method") or "install"))
|
|
script = inline[0].firstChild.data if inline and inline[0].firstChild else ""
|
|
ok, stderr = bash_syntax_ok(script)
|
|
check(ok, "FILE (Method=%s) script is valid bash%s" % (
|
|
f.getAttribute("Method") or "install", "" if ok else ": " + stderr.strip()))
|
|
if f.getAttribute("Method") == "remove":
|
|
remove_script = script
|
|
else:
|
|
install_script = script
|
|
|
|
check(install_script is not None, "found the install script")
|
|
check(remove_script is not None, "found the remove script")
|
|
check("lgtv-audiocap-loopback" in (install_script or ""),
|
|
"&name; entity actually expanded inside the install script (not left literal)")
|
|
|
|
# The plugin appends to /boot/config/go; redirect that at a scratch file
|
|
# to exercise the real install/remove logic end to end, not just parse it.
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
go = os.path.join(tmp, "go")
|
|
with open(go, "w") as fh:
|
|
fh.write("#!/bin/bash\n/usr/local/sbin/emhttp\n")
|
|
original = open(go).read()
|
|
|
|
# modprobe isn't run for real here; the script already tolerates that
|
|
# (it warns and continues), so there's nothing to stub out beyond
|
|
# keeping its stderr out of /tmp.
|
|
env_script = install_script.replace("GO=/boot/config/go", "GO=%s" % go)
|
|
env_script = env_script.replace("/tmp/${NAME}.err", os.path.join(tmp, "err"))
|
|
subprocess.run(["bash", "-c", env_script], check=True)
|
|
after_install = open(go).read()
|
|
check(after_install != original, "install actually appended something to go")
|
|
check("modprobe snd-aloop" in after_install, "the modprobe line ended up in go")
|
|
|
|
subprocess.run(["bash", "-c", env_script], check=True)
|
|
after_second_install = open(go).read()
|
|
check(after_second_install == after_install, "installing twice does not duplicate the block")
|
|
|
|
env_remove = remove_script.replace("GO=/boot/config/go", "GO=%s" % go)
|
|
env_remove = env_remove.replace("/sbin/rmmod snd_aloop 2>/dev/null || true", "true")
|
|
subprocess.run(["bash", "-c", env_remove], check=True)
|
|
after_remove = open(go).read()
|
|
check(after_remove == original, "remove restores go to its original contents exactly")
|
|
|
|
print()
|
|
print("%d/%d checks passed" % (passed, passed + failed))
|
|
return 0 if failed == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|