Let the Device field be picked, not typed blind

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>
This commit is contained in:
Rene Kievits
2026-08-26 13:51:02 +02:00
co-authored by Claude Opus 5
parent f3a4cddfd6
commit 0759cc00aa
10 changed files with 380 additions and 2 deletions
+15
View File
@@ -64,6 +64,21 @@ echo "== Capture pipeline end to end"
"$CC" "${CFLAGS[@]}" -o "$OUT/engine_smoke" test/engine_smoke.c "${SOURCES[@]}" -lpthread -lm
"$OUT/engine_smoke"
echo
echo "== Unraid plugin"
python3 test/verify_unraid_plugin.py
echo
echo "== Receiver container"
if command -v docker >/dev/null 2>&1; then
docker build -f docker/Dockerfile -t lgtv-audiocap-receiver:test-run . >/dev/null 2>&1
docker run --rm lgtv-audiocap-receiver:test-run --help >/dev/null
echo " ok image builds and forwards --help"
docker rmi lgtv-audiocap-receiver:test-run >/dev/null 2>&1
else
echo " SKIP: docker is not installed"
fi
echo
echo "== Frontend"
if command -v node >/dev/null 2>&1; then
+15
View File
@@ -191,6 +191,10 @@ async function main() {
eq('stops again', $('state-pill').textContent, 'Stopped');
console.log('diagnostics');
// Diagnostics run once automatically at boot, so the picker is already
// there — the user should not have to press the button first.
check('device picker already present from the boot-time diagnostics run',
!!doc.querySelector('[data-path="capture.device"].choice'));
click($('run-diagnostics'));
await wait(200);
check('diagnostics output shown',
@@ -200,6 +204,17 @@ async function main() {
await wait(200);
check('log output shown', $('output').textContent.indexOf('browser mock') >= 0);
console.log('device picker');
const picker = doc.querySelector('[data-path="capture.device"].choice');
check('device picker appears once sources are known', !!picker);
eq('picker starts on Automatic', picker.textContent, 'Automatic (@DEFAULT_MONITOR@)');
click(picker); // Automatic -> tpcm_output.monitor
await wait(600);
eq('picking a source reaches settings',
window.App.state.settings.capture.device, 'tpcm_output.monitor');
eq('the plain device field reflects the pick',
doc.querySelector('input[data-path="capture.device"]').value, 'tpcm_output.monitor');
console.log('navigation');
fakeLayout(window);
const tabs = doc.querySelectorAll('.tab');
+108
View File
@@ -0,0 +1,108 @@
#!/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())