Compare commits

...
4 Commits
Author SHA1 Message Date
Rene KievitsandClaude Opus 5 d2931bee63 Bump to 1.0.1
Every rebuild since 1.0.0 kept the same version, so the ipk filename
and repo.json content never changed even though the contents did --
indistinguishable from a stale cache to both Homebrew Channel's
update check and any HTTP cache sitting between the release and the
TV. Bumping the version changes the ipk's filename outright, which
sidesteps that ambiguity regardless of what's caching what.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:00:46 +02:00
Rene KievitsandClaude Opus 5 0759cc00aa 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>
2026-08-26 13:51:02 +02:00
Rene KievitsandClaude Opus 5 f3a4cddfd6 Package through a pinned Node, not whatever the host has
Found the real cause of "-5: ipk verify failed": ares-package's own
packaging code (ar-async/fstream/tar, last touched 2017-2019) silently
zeroes every file's mtime in the ipk when run under a sufficiently new
Node (reproduced on v25.8.1; a Node-18 container was unaffected).
Confirmed by unpacking data.tar.gz from our built ipk (every entry
1970-01-01) versus webosbrew/hyperhdr-webos-loader's real published
ipk, structurally identical to ours (web app + native service, same
control file, same ares-cli) but with genuine October 2025 timestamps.
The archive still parses fine everywhere generic tooling looks —
Python's tarfile, our own ar/tar inspection — so nothing here ever
errors. The TV's own installer is what eventually rejects it, and it
gives no hint why.

This took three rounds of elimination to isolate: root elevation
wasn't it (Homebrew Channel's own root status was "ok"), and
system-wide native-code verification wasn't it either (a real
native-service app installed fine on the same TV). Comparing our ipk
against that known-good one byte-for-byte was what surfaced the
timestamp anomaly, and rebuilding under Node 18 reproduced correct
timestamps immediately.

build.sh now runs ares-package inside a pinned node:18 container by
default, falling back to the host's Node with a warning if Docker
isn't available. Rebuilt the actual release ipk this way and
regenerated manifest.json/repo.json against its corrected hash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:51:13 +02:00
Rene KievitsandClaude Opus 5 f622c3a0bf Fix repo.json: embed the manifest, don't just list its fields
Read the Homebrew Channel's own DetailsPanel.js: when an app is opened
from Browse, refresh() only ever takes entry.manifest directly, or
fetches entry.manifestUrl if that's absent — never the entry's own
top-level fields. Our repo.json had neither, so
resolveURL(undefined, repositoryURL) threw before the fetch could even
start, and the details screen spun on "Loading" forever with no visible
error. This is what happened when it was tried against a real repo.

Fixed by nesting the full manifest under a "manifest" key per package
entry, alongside the id/title/iconUri the Browse grid reads directly.
Verified structurally (grid fields present, manifest embedded and
non-empty, ipkUrl absolute) since there's no local Homebrew Channel
build to run this against.

Also caught in the same investigation: a stale --base-url in the docs
used the tag v1.0.0 while the actual release was tagged 1.0.0, which
404s the icon and ipk silently — same symptom, different cause. Noted
in both the tool's docstring and the docs that a tag mismatch looks
identical to the manifest bug from the client's side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:30:24 +02:00
16 changed files with 493 additions and 31 deletions
+3
View File
@@ -116,6 +116,9 @@ native/ the webOS service: capture, DSP, sinks, Luna API (C)
frontend/ the on-TV app (plain HTML/CSS/JS, no framework) frontend/ the on-TV app (plain HTML/CSS/JS, no framework)
servicefiles/ services.json, package.json and the boot script servicefiles/ services.json, package.json and the boot script
host/ the receiver and loopback setup for the HyperHDR machine host/ the receiver and loopback setup for the HyperHDR machine
docker/ the receiver, packaged as a container (e.g. for Unraid)
unraid/ the plugin for the one part a container can't do: the
ALSA loopback kernel module, persisted across reboots
tools/ build, packaging, asset generation, on-TV probe tools/ build, packaging, asset generation, on-TV probe
test/ host-side tests: wire formats, the capture pipeline, the UI test/ host-side tests: wire formats, the capture pipeline, the UI
docs/ the longer explanations docs/ the longer explanations
+20
View File
@@ -0,0 +1,20 @@
# Runs host/lgtv-audiocap-receiver.py as a container instead of a systemd
# unit — for setups (e.g. Unraid) where Docker is the native way to run
# anything, but the ALSA loopback itself still has to be loaded on the real
# host kernel first (see unraid/lgtv-audiocap-loopback.plg or
# host/install-loopback.sh --method alsa, whichever fits the host).
#
# Build from the repo root, not this directory, so the image always tracks
# the same receiver the systemd install path uses — no second copy to drift:
# docker build -f docker/Dockerfile -t lgtv-audiocap-receiver .
FROM alpine:3.20
RUN apk add --no-cache python3 alsa-utils
COPY host/lgtv-audiocap-receiver.py /usr/local/bin/lgtv-audiocap-receiver.py
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /usr/local/bin/lgtv-audiocap-receiver.py /entrypoint.sh
EXPOSE 5004/udp
ENTRYPOINT ["/entrypoint.sh"]
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Maps environment variables onto lgtv-audiocap-receiver.py's flags, since
# that's how Unraid (and most container UIs) expose configuration — nobody
# wants to hand-edit a CLI in the "extra parameters" box.
set -eu
args="--port ${PORT:-5004} --bind ${BIND:-0.0.0.0}"
args="$args --output ${OUTPUT:-aplay} --device ${DEVICE:-hw:Loopback,0,0}"
args="$args --rate ${RATE:-48000} --channels ${CHANNELS:-2}"
args="$args --latency-ms ${LATENCY_MS:-80} --prebuffer-ms ${PREBUFFER_MS:-60}"
args="$args --max-gap ${MAX_GAP:-200} --reset-after ${RESET_AFTER:-5.0}"
args="$args --stats ${STATS:-30}"
[ -n "${MULTICAST:-}" ] && args="$args --multicast $MULTICAST"
[ -n "${IFACE:-}" ] && args="$args --iface $IFACE"
[ "${FILL_SILENCE:-1}" = "0" ] && args="$args --no-fill-silence"
# Anything passed on the "docker run" command line (or Unraid's "Extra
# Parameters") is appended last, so it can override an env-derived flag —
# and so plain `--help` works instead of silently starting the daemon.
echo "lgtv-audiocap-receiver.py $args $*"
# shellcheck disable=SC2086
exec python3 /usr/local/bin/lgtv-audiocap-receiver.py $args "$@"
+34 -14
View File
@@ -27,8 +27,20 @@ Everywhere else, build the native part in a container:
That bakes the SDK into an image, so it downloads once and later builds start That bakes the SDK into an image, so it downloads once and later builds start
immediately. There are aarch64 and x86_64 SDK builds and the image picks immediately. There are aarch64 and x86_64 SDK builds and the image picks
whichever matches the container, so on Apple Silicon it runs natively rather whichever matches the container, so on Apple Silicon it runs natively rather
than under emulation. Only the compile happens in the container; packaging and than under emulation. Only the compile happens in the container; deployment
deployment run on the host, where the TV is reachable. runs on the host, where the TV is reachable.
Packaging (`ares-package`) also runs in a container by default — pinned to
Node 18, not whatever Node the host has. This isn't optional hygiene: on a
newer Node (v22+, confirmed on v25) `ares-package`'s own dependencies
(`fstream`/`tar`, last touched around 2017-2019) silently zero out every
timestamp in the ipk instead of erroring. The archive still parses fine
everywhere generic tools look, so nothing here fails — the TV's installer is
what eventually rejects it, as an opaque `-5: ipk verify failed` with no
indication why. If Docker isn't available, `build.sh` falls back to the host's
own Node with a warning; if installs fail mysteriously in that mode, this is
the first thing to suspect — checked by unpacking `data.tar.gz` from the ipk
and confirming the timestamps aren't 1970-01-01.
Register the TV with ares once, using the Homebrew Channel's ssh (port 9922, Register the TV with ares once, using the Homebrew Channel's ssh (port 9922,
root): root):
@@ -113,29 +125,37 @@ or ssh — the app installs itself once the TV can reach a URL.
### Your own repository (no review, no waiting) ### Your own repository (no review, no waiting)
The Homebrew Channel's *Settings → Repositories → Add repository* accepts any The Homebrew Channel's *Settings → Repositories → Add repository* accepts any
URL that returns `{"packages": [...]}`, where each entry is the same manifest URL that returns `{"packages": [...]}`. Each entry needs its own `id`/
[`make-manifest.py`](../tools/make-manifest.py) already writes. Point one at `title`/`iconUri` for the Browse grid, plus the full manifest nested under a
your own git host's release assets and the app shows up in Browse with no `manifest` key for the details screen — [`make-manifest.py`](../tools/make-manifest.py)
submission process at all — this is what `--repo-out` (on by default) is for. builds exactly that shape. Point one at your own git host's release assets and
the app shows up in Browse with no submission process at all — this is what
`--repo-out` (on by default) is for.
1. Bump `version` in `frontend/appinfo.json`, `servicefiles/package.json` and 1. Bump `version` in `frontend/appinfo.json`, `servicefiles/package.json` and
`package.json`. `package.json`.
2. Build the ipk: `./tools/docker-build.sh && ./tools/build.sh package` (or 2. Build the ipk: `./tools/docker-build.sh && ./tools/build.sh package` (or
`./tools/build.sh` on Linux with the NDK installed). `./tools/build.sh` on Linux with the NDK installed).
3. Create a release tagged e.g. `v1.0.0` and attach three files to it: the 3. Create a release — note the **exact tag** Gitea/GitHub gives it, `1.0.0` or
ipk, `frontend/assets/icon.png`, and a repo index generated with `v1.0.0`, whichever it actually is — and attach three files: the ipk,
`--base-url` set to that release's asset URL: `frontend/assets/icon.png`, and a repo index generated with `--base-url`
set to that release's real download URL:
```sh ```sh
python3 tools/make-manifest.py \ python3 tools/make-manifest.py \
--base-url https://git.crylia.de/Crylia/lgtv_audio_cap/releases/download/v1.0.0 --base-url https://git.crylia.de/Crylia/lgtv_audio_cap/releases/download/1.0.0
# -> out/manifest.json (one app entry) # -> out/manifest.json (one app entry, for the official-repo route below)
# -> out/repo.json (that entry wrapped as {"packages": [...]}) # -> out/repo.json ({"packages": [{id, title, iconUri, manifest: {...}}]})
``` ```
A mismatched tag in `--base-url` doesn't error — it just makes the icon and
ipk links inside `repo.json` 404 silently, which looks identical to "the
details screen hangs" from the client's point of view. If the app was
already added and only the tag was wrong, re-run with the fixed tag and
re-upload `repo.json`; no need to touch the "Add repository" entry itself,
since its URL didn't change.
Attach `out/repo.json` itself too — its own download URL is what you paste Attach `out/repo.json` itself too — its own download URL is what you paste
into the TV, and it must match `--base-url` exactly or the ipk/icon links into the TV.
inside it point at the wrong place.
4. On the TV: Homebrew Channel → gear icon → *Add repository* → paste the 4. On the TV: Homebrew Channel → gear icon → *Add repository* → paste the
`repo.json` release URL → back out to Browse → find *Audio Cap* → Install. `repo.json` release URL → back out to Browse → find *Audio Cap* → Install.
+34
View File
@@ -40,6 +40,40 @@ sudo modprobe snd-aloop index=10 pcm_substreams=1 id=Loopback
./host/lgtv-audiocap-receiver.py --output aplay --device hw:Loopback,0,0 ./host/lgtv-audiocap-receiver.py --output aplay --device hw:Loopback,0,0
``` ```
### On Unraid
Unraid boots from a read-only USB image, so nothing here can be "just a
systemd service" — the loopback and the receiver need to be split into the
one part that genuinely needs the bare-metal kernel and the part that doesn't.
**The loopback (bare metal):** install
[`unraid/lgtv-audiocap-loopback.plg`](../unraid/lgtv-audiocap-loopback.plg) —
*Plugins → Install Plugin*, paste the raw URL to that file. It loads
`snd-aloop` immediately and adds one line to `/boot/config/go` so it survives
a reboot; *Plugins → Uninstall* removes exactly that line and nothing else.
**The receiver (a normal container):** build
[`docker/Dockerfile`](../docker/Dockerfile) and add it like any other Unraid
container — *Docker → Add Container*:
| Setting | Value |
| --- | --- |
| Repository | your image, e.g. `192.168.0.4:5000/lgtv-audiocap-receiver` |
| Network Type | Bridge (or Host, either works — it only ever listens on one UDP port) |
| Port | `5004` UDP → `5004` |
| Extra Parameters | `--device /dev/snd:/dev/snd` |
It's entirely configured through environment variables — see
[`docker/entrypoint.sh`](../docker/entrypoint.sh) for the full list
(`PORT`, `DEVICE`, `RATE`, `CHANNELS`, `LATENCY_MS`, …). The default `DEVICE`
is already `hw:Loopback,0,0`, so nothing needs setting for the common case.
Point the **HyperHDR container** at the loopback the same way: add
`--device /dev/snd:/dev/snd` to its extra parameters too, then use
`hw:Loopback,1,0` in its Sound Capture settings. Both containers reach the
same host kernel device, so no networking between them is needed for this
part — only the TV needs to know the host's IP, for the RTP stream itself.
### On the TV ### On the TV
*Outputs → HyperHDR audio (RTP/L16)* *Outputs → HyperHDR audio (RTP/L16)*
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"id": "org.webosbrew.audiocap", "id": "org.webosbrew.audiocap",
"version": "1.0.0", "version": "1.0.1",
"vendor": "Homebrew", "vendor": "Homebrew",
"type": "web", "type": "web",
"main": "index.html", "main": "index.html",
+67 -1
View File
@@ -188,6 +188,49 @@
}; };
} }
// Typing a PulseAudio source name blind, off a diagnostics dump you can
// only read on the TV itself, is exactly the kind of thing a D-pad picker
// exists for. Parsed from the same "pactl list short sources" text that
// System > Run diagnostics already fetches — nothing new to ask the
// service for. Format is tab-separated: index, name, driver, sample_spec,
// state.
function pulseSourceOptions() {
var diag = state.diagnostics && state.diagnostics.system;
var text = diag && diag.pactlSources;
var out = [{ value: '', label: 'Automatic (@DEFAULT_MONITOR@)' }];
if (!text) {
return out;
}
text.split('\n').forEach(function (line) {
var cols = line.split('\t');
var name = cols[1];
if (!name) {
return;
}
var running = cols[4] ? ' — ' + cols[4] : '';
out.push({ value: name, label: name + running });
});
return out;
}
// alsaCapturePcms lines look like "00-01: ALC1220 Analog : ... : capture 1"
// — "00-01" is card 0, device 1, so hw:0,1. Best-effort: a line that does
// not start with that pattern is skipped rather than guessed at.
function alsaDeviceOptions() {
var diag = state.diagnostics && state.diagnostics.system;
var lines = (diag && diag.alsaCapturePcms) || [];
var out = [{ value: '', label: 'Automatic (default)' }];
lines.forEach(function (line) {
var m = /^(\d+)-(\d+):\s*(.*)$/.exec(line);
if (!m) {
return;
}
var hw = 'hw:' + parseInt(m[1], 10) + ',' + parseInt(m[2], 10);
out.push({ value: hw, label: hw + ' — ' + m[3] });
});
return out;
}
var CAPTURE_FIELDS = [ var CAPTURE_FIELDS = [
{ {
path: 'capture.backend', label: 'Backend', type: 'choice', path: 'capture.backend', label: 'Backend', type: 'choice',
@@ -199,7 +242,26 @@
when: backendIs(['auto', 'pulse', 'alsa']), when: backendIs(['auto', 'pulse', 'alsa']),
placeholder: 'blank = default monitor', placeholder: 'blank = default monitor',
hint: 'PulseAudio source name, or an ALSA PCM such as hw:0,0. ' hint: 'PulseAudio source name, or an ALSA PCM such as hw:0,0. '
+ 'Run diagnostics to see what this TV has.', + 'Run diagnostics, then use the picker below instead of typing.',
},
{
path: 'capture.device', label: 'Pick a discovered source', type: 'choice',
rebuild: true, wide: true,
when: function (s) {
return backendIs(['auto', 'pulse'])(s) && pulseSourceOptions().length > 1;
},
options: pulseSourceOptions,
hint: 'From the last diagnostics run. Press Enter to cycle through '
+ 'every source this TV reported; picking one fills the Device field above.',
},
{
path: 'capture.device', label: 'Pick a discovered device', type: 'choice',
rebuild: true, wide: true,
when: function (s) {
return backendIs(['alsa'])(s) && alsaDeviceOptions().length > 1;
},
options: alsaDeviceOptions,
hint: 'From the last diagnostics run.',
}, },
{ {
path: 'capture.server', label: 'PulseAudio server', type: 'text', wide: true, path: 'capture.server', label: 'PulseAudio server', type: 'text', wide: true,
@@ -714,6 +776,10 @@
function runDiagnostics() { function runDiagnostics() {
Luna.getDiagnostics(function (reply) { Luna.getDiagnostics(function (reply) {
state.diagnostics = reply;
// The Capture panel's device pickers are built from this same reply,
// so refresh it if that's the panel currently open.
renderCapture();
var copy = JSON.parse(JSON.stringify(reply)); var copy = JSON.parse(JSON.stringify(reply));
delete copy.returnValue; delete copy.returnValue;
showOutput(JSON.stringify(copy, null, 2)); showOutput(JSON.stringify(copy, null, 2));
+8 -1
View File
@@ -173,7 +173,14 @@
}, },
binaries: { parec: false, pactl: true, pacat: false, arecord: true }, binaries: { parec: false, pactl: true, pacat: false, arecord: true },
pulseSockets: ['/var/run/pulse/native'], pulseSockets: ['/var/run/pulse/native'],
pactlSources: 'mock output', // Realistic shape: a TV that mixes several per-app sinks down to
// one common output, the case the device picker exists for.
pactlSources: [
'0\ttpcm_output.monitor\tmodule-combine-sink.c\ts16le 2ch 48000Hz\tRUNNING',
'1\ttpmedia.monitor\tmodule-alsa-card.c\ts16le 2ch 48000Hz\tIDLE',
'2\ttpeffects.monitor\tmodule-alsa-card.c\ts16le 2ch 48000Hz\tIDLE',
'3\ttptts.monitor\tmodule-alsa-card.c\ts16le 2ch 48000Hz\tSUSPENDED',
].join('\n'),
alsaCards: ['0 [Loopback]: Loopback - Loopback'], alsaCards: ['0 [Loopback]: Loopback - Loopback'],
alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'], alsaCapturePcms: ['00-01: Loopback PCM : playback 1 : capture 1'],
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lgtv-audio-cap", "name": "lgtv-audio-cap",
"version": "1.0.0", "version": "1.0.1",
"private": true, "private": true,
"description": "Captures audio on an LG webOS 5/6 TV and streams it out \u2014 HyperHDR first, plus raw UDP, TCP and HTTP.", "description": "Captures audio on an LG webOS 5/6 TV and streams it out \u2014 HyperHDR first, plus raw UDP, TCP and HTTP.",
"keywords": [ "keywords": [
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"id": "org.webosbrew.audiocap.service", "id": "org.webosbrew.audiocap.service",
"version": "1.0.0", "version": "1.0.1",
"description": "Captures TV audio and streams it to HyperHDR and other receivers", "description": "Captures TV audio and streams it to HyperHDR and other receivers",
"main": "audiocap-service" "main": "audiocap-service"
} }
+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 "$CC" "${CFLAGS[@]}" -o "$OUT/engine_smoke" test/engine_smoke.c "${SOURCES[@]}" -lpthread -lm
"$OUT/engine_smoke" "$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
echo "== Frontend" echo "== Frontend"
if command -v node >/dev/null 2>&1; then 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'); eq('stops again', $('state-pill').textContent, 'Stopped');
console.log('diagnostics'); 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')); click($('run-diagnostics'));
await wait(200); await wait(200);
check('diagnostics output shown', check('diagnostics output shown',
@@ -200,6 +204,17 @@ async function main() {
await wait(200); await wait(200);
check('log output shown', $('output').textContent.indexOf('browser mock') >= 0); 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'); console.log('navigation');
fakeLayout(window); fakeLayout(window);
const tabs = doc.querySelectorAll('.tab'); 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())
+44 -6
View File
@@ -68,17 +68,55 @@ EOF
echo "$toolchain" echo "$toolchain"
} }
check_ares() { PACKAGER_NODE_IMAGE="${PACKAGER_NODE_IMAGE:-node:18-bookworm-slim}"
command -v ares-package >/dev/null 2>&1 || cat >&2 <<'EOF'
error: ares-package is not on PATH
npm install -g @webosose/ares-cli check_ares() {
[ -f "$ROOT/node_modules/@webosose/ares-cli/bin/ares-package.js" ] && return 0
command -v ares-package >/dev/null 2>&1 && return 0
cat >&2 <<'EOF'
error: ares-cli is not installed
npm install
Then register the TV once (developer mode or the Homebrew Channel's ssh): Then register the TV once (developer mode or the Homebrew Channel's ssh):
ares-setup-device --add tv --info "{'host':'192.168.1.20','port':9922,'username':'root'}" ares-setup-device --add tv --info "{'host':'192.168.1.20','port':9922,'username':'root'}"
EOF EOF
command -v ares-package >/dev/null 2>&1 return 1
}
# ares-package's own packaging code (ar-async/fstream/tar, all last touched
# around 2017-2019) silently mishandles file metadata on very new Node
# releases: every mtime in the ipk comes out as 1970-01-01 instead of the real
# date. The archive still parses, so nothing here errors — the TV's installer
# is what eventually rejects it, as "ipk verify failed" with no clue why.
# Running the same ares-cli under a pinned, known-good Node avoids the whole
# class of bug regardless of what's on the host.
run_ares_package() {
local ares_js="$ROOT/node_modules/@webosose/ares-cli/bin/ares-package.js"
if [ -f "$ares_js" ] && command -v docker >/dev/null 2>&1; then
# Arguments are host paths under $ROOT; rewrite them to the container's
# mount point since nothing outside $ROOT is visible in there.
local args=() a
for a in "$@"; do
args+=("${a/#$ROOT//src}")
done
docker run --rm \
--volume "$ROOT:/src" \
--workdir /src \
--user "$(id -u):$(id -g)" \
--env HOME=/tmp \
"$PACKAGER_NODE_IMAGE" \
node /src/node_modules/@webosose/ares-cli/bin/ares-package.js "${args[@]}"
return
fi
if [ -f "$ares_js" ]; then
say "warning: no docker, running ares-package on the host's own Node ($(node --version 2>/dev/null))"
say " if the ipk fails to install with a vague error, re-run with docker installed"
node "$ares_js" "$@"
return
fi
ares-package "$@"
} }
build_native() { build_native() {
@@ -132,7 +170,7 @@ package() {
step "Packaging" step "Packaging"
mkdir -p "$OUT_DIR" mkdir -p "$OUT_DIR"
rm -f "$OUT_DIR"/${APP_ID}_*.ipk rm -f "$OUT_DIR"/${APP_ID}_*.ipk
ares-package "$STAGE_APP" "$STAGE_SERVICE" -o "$OUT_DIR" run_ares_package "$STAGE_APP" "$STAGE_SERVICE" -o "$OUT_DIR"
local ipk local ipk
ipk="$(ls -t "$OUT_DIR"/${APP_ID}_*.ipk | head -1)" ipk="$(ls -t "$OUT_DIR"/${APP_ID}_*.ipk | head -1)"
+32 -6
View File
@@ -6,15 +6,27 @@ The manifest (out/manifest.json) is one app entry: id, ipkUrl, ipkHash, and so
on. It is what you submit to webosbrew/apps-repo to get into the official on. It is what you submit to webosbrew/apps-repo to get into the official
store. store.
The repo index (out/repo.json) is that same entry wrapped as The repo index (out/repo.json) is `{"packages": [...]}`, which is the format
`{"packages": [...]}`, which is the format the Homebrew Channel's own the Homebrew Channel's own "Add repository" dialog expects (Settings ->
"Add repository" dialog expects (Settings -> Repositories -> Add repository). Repositories -> Add repository). Host it anywhere static, paste its URL in,
Host it anywhere static, paste its URL in, and the app shows up in Browse — and the app shows up in Browse — no submission, no review, no shell access to
no submission, no review, no shell access to the TV at all. the TV at all.
Each package entry embeds the full manifest under a "manifest" key. That is
not decoration: the app's details screen (DetailsPanel.refresh(), read
straight from its source) only ever uses entry.manifest directly, or fetches
entry.manifestUrl if entry.manifest is absent. Ship a repo.json without either
one and the details view calls resolveURL(undefined, ...), throws, and spins
on "Loading" forever with no error shown. Embedding beats a manifestUrl
because there is only one file to keep in sync.
python3 tools/make-manifest.py \\ python3 tools/make-manifest.py \\
--base-url https://git.example/you/lgtv-audio-cap/releases/download/v1.0.0 --base-url https://git.example/you/lgtv-audio-cap/releases/download/v1.0.0
--base-url must be the exact release download URL, tag and all — the icon and
ipk links are absolute, so a tag typo (v1.0.0 vs 1.0.0) 404s silently rather
than falling back to anything.
Defaults to the newest ipk in out/ and writes out/manifest.json + out/repo.json. Defaults to the newest ipk in out/ and writes out/manifest.json + out/repo.json.
""" """
@@ -91,9 +103,23 @@ def main():
print("\nwrote %s" % os.path.relpath(args.out, ROOT), file=sys.stderr) print("\nwrote %s" % os.path.relpath(args.out, ROOT), file=sys.stderr)
if args.repo_out: if args.repo_out:
# The grid view (BrowserPanel) reads id/title/iconUri straight off the
# package entry. The details view (DetailsPanel) only ever looks at
# entry.manifest directly, or fetches entry.manifestUrl if that is
# missing — never the entry's own top-level fields. Skipping
# manifestUrl (a second file, a second URL to keep in sync) by
# embedding the manifest here means DetailsPanel takes its
# already-ready fast path and never issues that fetch at all.
package_entry = {
"id": manifest["id"],
"title": manifest["title"],
"iconUri": manifest["iconUri"],
"shortDescription": manifest["appDescription"],
"manifest": manifest,
}
os.makedirs(os.path.dirname(args.repo_out) or ".", exist_ok=True) os.makedirs(os.path.dirname(args.repo_out) or ".", exist_ok=True)
with open(args.repo_out, "w") as fh: with open(args.repo_out, "w") as fh:
json.dump({"packages": [manifest]}, fh, indent=2) json.dump({"packages": [package_entry]}, fh, indent=2)
fh.write("\n") fh.write("\n")
print("wrote %s" % os.path.relpath(args.repo_out, ROOT), file=sys.stderr) print("wrote %s" % os.path.relpath(args.repo_out, ROOT), file=sys.stderr)
+87
View File
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<!DOCTYPE PLUGIN [
<!ENTITY name "lgtv-audiocap-loopback">
<!ENTITY author "Crylia">
<!ENTITY version "2026.08.26">
<!ENTITY pluginURL "https://git.crylia.de/Crylia/lgtv_audio_cap/raw/branch/main/unraid/&name;.plg">
]>
<!--
This plugin does exactly one thing: load the ALSA loopback (snd-aloop) that
the LG TV Audio Cap RTP receiver plays into, and make that persist across an
Unraid reboot. Everything else - actually receiving the TV's audio and
writing it into the loopback - runs as a normal Docker container (see
docker/Dockerfile in the project repo), because that part doesn't need
bare-metal access. Only the kernel module load does: Unraid boots from a
read-only USB image each time, so anything not re-applied via /boot/config/go
or a plugin is gone on the next boot, and a container can't load a host
kernel module for itself.
hw:Loopback,0,0 - playback end, feed this to the receiver container
hw:Loopback,1,0 - capture end, point HyperHDR's sound capture at this
-->
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="6.9.0">
<CHANGES>
###2026.08.26
- Initial release.
</CHANGES>
<FILE Run="/bin/bash">
<INLINE>
set -e
NAME="&name;"
MARK="# ${NAME}: load ALSA loopback for LG TV Audio Cap (do not remove this line by hand)"
LOAD_CMD="/sbin/modprobe snd-aloop index=10 pcm_substreams=1 id=Loopback"
GO=/boot/config/go
echo "Installing ${NAME} &version;"
if ! $LOAD_CMD 2>/tmp/${NAME}.err; then
echo "warning: snd-aloop failed to load, see /tmp/${NAME}.err"
echo " this Unraid build's kernel may not include it"
fi
if ! grep -qF "$MARK" "$GO" 2>/dev/null; then
{
echo "$MARK"
echo "$LOAD_CMD"
} >> "$GO"
echo "Added the loopback load to $GO -- it will now load on every boot."
else
echo "$GO already loads the loopback; left it alone."
fi
echo ""
echo "Done. Check: cat /proc/asound/cards | grep -i loopback"
echo "In your receiver container's device settings, use hw:Loopback,0,0."
echo "In HyperHDR's sound capture settings, use hw:Loopback,1,0."
</INLINE>
</FILE>
<FILE Run="/bin/bash" Method="remove">
<INLINE>
set -e
NAME="&name;"
MARK="# ${NAME}: load ALSA loopback for LG TV Audio Cap (do not remove this line by hand)"
GO=/boot/config/go
if [ -f "$GO" ]; then
if grep -qF "$MARK" "$GO"; then
awk -v mark="$MARK" '
$0 == mark { skip = 1; next }
skip > 0 { skip--; next }
{ print }
' "$GO" > "${GO}.tmp"
mv "${GO}.tmp" "$GO"
echo "Removed the loopback load from $GO."
fi
fi
/sbin/rmmod snd_aloop 2>/dev/null || true
echo "${NAME} removed. The loopback will not load on the next boot."
</INLINE>
</FILE>
</PLUGIN>