diff --git a/docker-compose.yml b/docker-compose.yml index 95609c0..d1672b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,27 @@ # inside Xvfb, captures the frames + audio, and (post phase 1) speaks the TS3 # voice protocol directly to push the result into a TS6 channel via the # server's built-in stream feature. No HLS, no second nginx container. +# +# Two networking modes are available via compose profiles: +# +# * default (profile "bridge"): bot runs in the standard ts6-net bridge. +# Simple, isolated, but WebRTC ICE candidate gathering can struggle - +# STUN responses to the docker bridge IP often don't establish a +# usable srflx candidate, so peers can't NAT-punch back to the bot. +# +# * profile "host": bot runs in the host network namespace. The host's +# real LAN/WAN IPs become the ICE candidates, STUN works normally, +# and viewers can reach the bot. Trade-off: shares the host's port +# space (8080 must be free on the host), needs the TS6 server +# reachable via 127.0.0.1 / a public hostname. +# +# Use one or the other, not both: +# +# docker compose --profile bridge up -d # original behaviour +# docker compose --profile host up -d # works around ICE failures +# +# Without --profile only the bridge service runs (matching prior behaviour +# so existing setups keep working unchanged). services: bot: @@ -11,16 +32,14 @@ services: context: . dockerfile: docker/Dockerfile container_name: ts6-stream-bot + profiles: ["", "bridge"] restart: unless-stopped env_file: .env - # We need ipc=host or a large /dev/shm so Chromium doesn't crash with - # "out of shared memory" on big frames. shm_size: "1gb" volumes: - # Mount sources read-only so a code edit doesn't require rebuild during dev - ./src:/app/src:ro ports: - - "127.0.0.1:8080:8080" # control API - keep local; expose via reverse proxy if needed + - "127.0.0.1:8080:8080" networks: - ts6-net healthcheck: @@ -30,6 +49,25 @@ services: retries: 3 start_period: 20s + bot-host: + build: + context: . + dockerfile: docker/Dockerfile + container_name: ts6-stream-bot + profiles: ["host"] + restart: unless-stopped + env_file: .env + shm_size: "1gb" + network_mode: host + volumes: + - ./src:/app/src:ro + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + networks: ts6-net: external: true diff --git a/src/ts6_stream_bot/pipeline/stream_publisher.py b/src/ts6_stream_bot/pipeline/stream_publisher.py index 3590aa0..558b2bf 100644 --- a/src/ts6_stream_bot/pipeline/stream_publisher.py +++ b/src/ts6_stream_bot/pipeline/stream_publisher.py @@ -334,6 +334,19 @@ async def _on_ice_state_change() -> None: offer_sdp = pc.localDescription.sdp + # Surface the local ICE candidate set so we can tell at a glance + # whether STUN/TURN actually contributed anything. The trace we + # need to debug "ICE failed" almost always boils down to "the bot + # only has its docker-bridge host candidate, no srflx, no relay". + local_candidates = _parse_local_candidates(offer_sdp) + log.info( + "stream_publisher.local_ice_candidates", + clid=viewer_clid, + count=len(local_candidates), + kinds=sorted({c["typ"] for c in local_candidates}), + candidates=local_candidates, + ) + async with self._lock: self._viewers[viewer_clid] = _Viewer( clid=viewer_clid, pc=pc, joined_at=asyncio.get_event_loop().time() @@ -456,6 +469,28 @@ async def _wait_for_ice_gathering( await asyncio.sleep(poll) +def _parse_local_candidates(sdp: str) -> list[dict[str, str]]: + """Pull the c=...candidate lines out of an SDP into a structured form + so the diagnostic log shows "what did STUN actually give us".""" + out: list[dict[str, str]] = [] + for line in sdp.splitlines(): + if not line.startswith("a=candidate:"): + continue + parts = line[len("a=candidate:") :].split() + # Format: + # typ [raddr rport ] [tcptype ...] ... + if len(parts) < 7: + continue + entry: dict[str, str] = { + "protocol": parts[2].lower(), + "ip": parts[4], + "port": parts[5], + "typ": parts[7] if len(parts) > 7 and parts[6] == "typ" else "?", + } + out.append(entry) + return out + + __all__ = [ "StreamPublisher", "StreamPublisherStatus",