Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,42 @@
# 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:
build:
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:
Expand All @@ -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
35 changes: 35 additions & 0 deletions src/ts6_stream_bot/pipeline/stream_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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: <foundation> <component> <protocol> <priority> <ip> <port>
# typ <type> [raddr <ip> rport <port>] [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",
Expand Down
Loading