TV
ET Stream Bot ETLegacy TV-Bot + Twitch
Streaming How-To
Wolfenstein: Enemy Territory
1. Overview

This guide shows how to run an ETLegacy TV-Bot on a Linux server and stream your Wolfenstein: Enemy Territory server to Twitch – without any desktop session, using only terminal + ffmpeg.

Components

  • ETLegacy client running as TV-Bot.
  • ffmpeg encoding game window to Twitch.
  • tvbot_supervisor.sh:
    • Starts ETL + stream.
    • Restarts both if something crashed.
    • Monitors ET log for disconnects.

Goals

  • 24/7 stream of your ET server.
  • No root required, everything under user tvbot.
  • One single script to start, stop, restart and status.
2. Requirements
  • Linux server with:
    • ETLegacy client installed (64-bit recommended).
    • ffmpeg installed from distro repo or static build.
    • User account tvbot (no root needed for running).
  • Working Enemy Territory server you want to spectate.
  • Twitch account and your stream key (keep it secret!).
Tip: Test everything in a screen/tmux session first. Once it runs stable, you can rely on the supervisor script to keep it alive.
3. Directory Layout

3.1 Home of the tvbot user

/home/tvbot/
  etlegacy/
    etl                       (ETLegacy binary)
    etl.sh                    (TV-Bot start script)
    tvbot_supervisor.sh       (Supervisor + watchdog)
    etltv.pid                 (PID file for ETL client)
    etltv.log                 (ETL console log)

  twitch_stream.pid           (PID file for ffmpeg)
  twitch_stream.log           (ffmpeg log)
  tw.sh                       (Twitch stream script)

3.2 Script overview

Scripts
File Role Notes
etl.sh Start ETLegacy TV client Runs ETL as spectator, connects to your server and logs to etltv.log.
tw.sh Start ffmpeg Twitch stream Captures ETL window / Xvfb and pushes to Twitch ingest.
tvbot_supervisor.sh Supervisor & watchdog Start/stop/restart/status & auto-restart on crashes/disconnects.
4. Step 1: Create tvbot user

Run as root once:

adduser --disabled-password --gecos "ET TV Bot" tvbot
# or:
# useradd -m tvbot

# switch to tvbot
su - tvbot

Inside tvbot home, create the folder:

mkdir -p /home/tvbot/etlegacy
cd /home/tvbot/etlegacy

Copy your ETLegacy binary + game data into this directory (or symlink it). Make sure ETL runs:

./etl +set fs_game etmain +quit
Note: This guide assumes ETLegacy binary is called etl and lives in /home/tvbot/etlegacy. If your path or name is different, adjust scripts accordingly.
5. Step 2: ETLegacy TV client (etl.sh)

Create /home/tvbot/etlegacy/etl.sh:

#!/bin/bash
# etl.sh – Start ETLegacy TV client for streaming
# User: tvbot (no root)

set -u

BASE_DIR="/home/tvbot/etlegacy"
ETL_BIN="${BASE_DIR}/etl"
PIDFILE="${BASE_DIR}/etltv.pid"
LOGFILE="${BASE_DIR}/etltv.log"

# Server you want to spectate
SERVER_IP="84.200.135.3"
SERVER_PORT="27960"

# Profile / config (adjust as needed)
PROFILE="tv"
CFG_EXEC="exec bot.cfg"

cd "${BASE_DIR}" || exit 1

# don't start if already running
if [ -f "${PIDFILE}" ]; then
  pid="$(cat "${PIDFILE}" 2>/dev/null || echo "")"
  if [ -n "${pid}" ] && kill -0 "${pid}" 2>/dev/null; then
    echo "ETL already running (pid ${pid})"
    exit 0
  fi
fi

echo "Starting ETLegacy TV client ..."

# You can also run this in Xvfb if needed
nohup "${ETL_BIN}" \
  +set fs_game "etmain" \
  +set cl_profile "${PROFILE}" \
  +set com_maxfps "30" \
  +set cg_drawfps "1" \
  +set vid_restart "1" \
  +connect "${SERVER_IP}:${SERVER_PORT}" \
  +${CFG_EXEC} \
  >>"${LOGFILE}" 2>&1 &

echo $! > "${PIDFILE}"
echo "ETL started (pid $(cat "${PIDFILE}"))"

Make it executable:

chmod +x /home/tvbot/etlegacy/etl.sh
Advice: Keep the ETL config for this TV-Bot profile as lean as possible (low resolution, stable 30 FPS, auto-spectate script, etc.).
6. Step 3: Twitch stream script (tw.sh)

Create /home/tvbot/tw.sh (outside of etlegacy/):

#!/bin/bash
# tw.sh – Start Twitch stream via ffmpeg
# User: tvbot

set -u

PIDFILE="$HOME/twitch_stream.pid"
LOGFILE="$HOME/twitch_stream.log"

# Twitch settings (replace with YOUR key)
TWITCH_KEY="YOUR_TWITCH_STREAM_KEY_HERE"
TWITCH_URL="rtmp://live.twitch.tv/app/${TWITCH_KEY}"

# Capture settings – adjust to your setup
# Example: capture from X11 :0.0 screen region 1280x720 at 30 FPS
DISPLAY=":0.0"
RESOLUTION="1280x720"
FRAMERATE="30"

# Audio source (PulseAudio example)
AUDIO_SOURCE="default"

if [ -f "${PIDFILE}" ]; then
  pid="$(cat "${PIDFILE}" 2>/dev/null || echo "")"
  if [ -n "${pid}" ] && kill -0 "${pid}" 2>/dev/null; then
    echo "Stream already running (pid ${pid})"
    exit 0
  fi
fi

echo "Starting Twitch stream ..."
{
  echo "===== $(date) – starting ffmpeg ====="
  ffmpeg \
    -f x11grab -video_size "${RESOLUTION}" -framerate "${FRAMERATE}" -i "${DISPLAY}" \
    -f pulse -i "${AUDIO_SOURCE}" \
    -c:v libx264 -preset veryfast -b:v 3500k -maxrate 3500k -bufsize 7000k \
    -pix_fmt yuv420p -g 60 \
    -c:a aac -b:a 160k -ar 44100 \
    -f flv "${TWITCH_URL}"
  echo "===== $(date) – ffmpeg stopped ====="
} >>"${LOGFILE}" 2>&1 &

echo $! > "${PIDFILE}"
echo "Stream started (pid $(cat "${PIDFILE}"))"

Make it executable:

chmod +x /home/tvbot/tw.sh
7. Step 4: Supervisor / Watchdog

Create /home/tvbot/etlegacy/tvbot_supervisor.sh with this content:

#!/bin/bash
# tvbot_supervisor.sh – All-in-one controller + watchdog for ETc|TV (etl.sh) and Twitch stream (tw.sh)
#
# Run as user: tvbot (NOT root)
#
# Commands:
#   ./tvbot_supervisor.sh start
#   ./tvbot_supervisor.sh stop
#   ./tvbot_supervisor.sh restart
#   ./tvbot_supervisor.sh status
#
# Extra watchdog feature:
#   - Watches etltv.log for "server disconnected" / lost connection / dropped
#     and triggers a full restart (ETL + stream).
#   - You can also extend the pattern to catch "Received signal 11" etc.

set -u

# ---- refuse root ----
if [ "${EUID:-$(id -u)}" -eq 0 ]; then
  echo "ERROR: Do not run this as root. Switch to user tvbot."
  exit 1
fi

BASE_DIR="$HOME/etlegacy"
ETL_SCRIPT="$BASE_DIR/etl.sh"
TW_SCRIPT="$HOME/tw.sh"

ETL_PIDFILE="$BASE_DIR/etltv.pid"
TW_PIDFILE="$HOME/twitch_stream.pid"

SUP_PIDFILE="$BASE_DIR/tvbot_supervisor.pid"
LOGFILE="$BASE_DIR/tvbot_supervisor.log"

ETL_LAST_START_FILE="$BASE_DIR/.etl_last_start"

# ---- disconnect / crash log watching ----
ETL_LOGFILE="$BASE_DIR/etltv.log"
ETL_LOGPOS_FILE="$BASE_DIR/.etl_logpos"
ETL_DISCOOLDOWN_FILE="$BASE_DIR/.etl_last_disconnect"
DISCONNECT_COOLDOWN=90   # seconds between disconnect-triggered restarts

CHECK_INTERVAL=10            # seconds for watchdog loop
START_WAIT=8                 # seconds to wait for pidfiles on start
WARMUP_AFTER_ETL_START=20    # seconds to wait before starting stream after ETL start/restart

cd "$BASE_DIR" || { echo "ERROR: $BASE_DIR not found"; exit 1; }

log() {
  echo "$(date '+%F %T') – $*" >> "$LOGFILE"
}

pid_running() {
  local pid="$1"
  [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}

running_from_pidfile() {
  local pidfile="$1"
  if [ -f "$pidfile" ]; then
    local pid
    pid=$(cat "$pidfile" 2>/dev/null || true)
    if pid_running "$pid"; then
      return 0
    fi
  fi
  return 1
}

wait_for_pidfile() {
  local pidfile="$1"
  local seconds="$2"
  local i
  for ((i=0; i "$ETL_LAST_START_FILE" 2>/dev/null || true
}

etl_warmup_wait() {
  local now last diff remain
  now=$(date +%s)
  last=0
  if [ -f "$ETL_LAST_START_FILE" ]; then
    last=$(cat "$ETL_LAST_START_FILE" 2>/dev/null || echo 0)
  fi
  diff=$((now - last))
  if [ "$diff" -lt "$WARMUP_AFTER_ETL_START" ]; then
    remain=$((WARMUP_AFTER_ETL_START - diff))
    log "warmup wait ${remain}s before stream start"
    sleep "$remain"
  fi
}

start_etl() {
  if running_from_pidfile "$ETL_PIDFILE"; then
    echo "ETL already running (pid $(cat "$ETL_PIDFILE"))."
    return 0
  fi

  if [ ! -x "$ETL_SCRIPT" ]; then
    echo "ERROR: $ETL_SCRIPT not found or not executable."
    return 1
  fi

  log "starting ETL via etl.sh"
  nohup setsid "$ETL_SCRIPT" >/dev/null 2>&1 &

  if wait_for_pidfile "$ETL_PIDFILE" "$START_WAIT"; then
    echo "ETL started (pid $(cat "$ETL_PIDFILE"))."
    log "ETL started ok (pid $(cat "$ETL_PIDFILE"))"
    record_etl_start
    return 0
  else
    echo "ETL start FAILED. Check: $BASE_DIR/etltv.log"
    log "ETL start failed"
    return 1
  fi
}

start_tw() {
  if running_from_pidfile "$TW_PIDFILE"; then
    echo "Stream already running (pid $(cat "$TW_PIDFILE"))."
    return 0
  fi

  if [ ! -x "$TW_SCRIPT" ]; then
    echo "ERROR: $TW_SCRIPT not found or not executable."
    return 1
  fi

  etl_warmup_wait

  log "starting Twitch stream via tw.sh"
  "$TW_SCRIPT" >> "$HOME/twitch_stream.log" 2>&1

  if wait_for_pidfile "$TW_PIDFILE" "$START_WAIT"; then
    echo "Stream started (pid $(cat "$TW_PIDFILE"))."
    log "Stream started ok (pid $(cat "$TW_PIDFILE"))"
    return 0
  else
    echo "Stream start FAILED. Check: $HOME/twitch_stream.log"
    log "Stream start failed"
    return 1
  fi
}

stop_tw() {
  if running_from_pidfile "$TW_PIDFILE"; then
    local pid
    pid=$(cat "$TW_PIDFILE")
    log "stopping ffmpeg (pid $pid)"
    kill "$pid" 2>/dev/null || true
    wait_for_exit "$pid" 8 || true
  fi
  rm -f "$TW_PIDFILE"
  echo "Stream stopped."
}

stop_etl() {
  if running_from_pidfile "$ETL_PIDFILE"; then
    local pid
    pid=$(cat "$ETL_PIDFILE")
    log "stopping ETL (pid $pid)"
    kill "$pid" 2>/dev/null || true
    wait_for_exit "$pid" 8 || true
  fi
  rm -f "$ETL_PIDFILE"
  echo "ETL stopped."
}

start_supervisor() {
  if running_from_pidfile "$SUP_PIDFILE"; then
    echo "Supervisor already running (pid $(cat "$SUP_PIDFILE"))."
    return 0
  fi

  log "starting supervisor loop"
  nohup setsid "$0" supervise >/dev/null 2>&1 &
  echo $! > "$SUP_PIDFILE"
  echo "Supervisor started (pid $(cat "$SUP_PIDFILE"))."
  log "supervisor started (pid $(cat "$SUP_PIDFILE"))"
}

stop_supervisor() {
  if running_from_pidfile "$SUP_PIDFILE"; then
    local pid
    pid=$(cat "$SUP_PIDFILE")
    log "stopping supervisor (pid $pid)"
    kill "$pid" 2>/dev/null || true
    wait_for_exit "$pid" 5 || true
  fi
  rm -f "$SUP_PIDFILE"
  echo "Supervisor stopped."
}

status_all() {
  if running_from_pidfile "$ETL_PIDFILE"; then
    echo "ETL: RUNNING (pid $(cat "$ETL_PIDFILE"))"
  else
    echo "ETL: STOPPED"
  fi

  if running_from_pidfile "$TW_PIDFILE"; then
    echo "Stream: RUNNING (pid $(cat "$TW_PIDFILE"))"
  else
    echo "Stream: STOPPED"
  fi

  if running_from_pidfile "$SUP_PIDFILE"; then
    echo "Supervisor: RUNNING (pid $(cat "$SUP_PIDFILE"))"
  else
    echo "Supervisor: STOPPED"
  fi
}

# ---- Detect "server disconnected" or crash in ET log (new lines only) ----
etl_log_has_disconnect() {
  [ -f "$ETL_LOGFILE" ] || return 1

  local size lastpos newdata now lastdisc diff

  size=$(stat -c%s "$ETL_LOGFILE" 2>/dev/null || echo 0)
  lastpos=0
  if [ -f "$ETL_LOGPOS_FILE" ]; then
    lastpos=$(cat "$ETL_LOGPOS_FILE" 2>/dev/null || echo 0)
  fi

  # log rotated / truncated
  if [ "$size" -lt "$lastpos" ]; then
    lastpos=0
  fi

  # read only new bytes
  if [ "$size" -gt "$lastpos" ]; then
    newdata=$(tail -c +$((lastpos+1)) "$ETL_LOGFILE" 2>/dev/null || true)
  else
    newdata=""
  fi

  echo "$size" > "$ETL_LOGPOS_FILE" 2>/dev/null || true

  # cooldown so 1 disconnect doesn't spam restarts
  now=$(date +%s)
  lastdisc=0
  if [ -f "$ETL_DISCOOLDOWN_FILE" ]; then
    lastdisc=$(cat "$ETL_DISCOOLDOWN_FILE" 2>/dev/null || echo 0)
  fi
  diff=$((now - lastdisc))
  if [ "$diff" -lt "$DISCONNECT_COOLDOWN" ]; then
    return 1
  fi

  # match common ET:L disconnect / crash messages (case-insensitive)
  echo "$newdata" | grep -Eiq \
    "server disconnected|Server Disconnected|CL_Disconnect|lost connection|Connection reset|was dropped|client disconnected|Disconnected from server|NET:.*disconnect|Received signal 11" \
    || return 1

  echo "$now" > "$ETL_DISCOOLDOWN_FILE" 2>/dev/null || true
  return 0
}

full_restart_due_disconnect() {
  log "disconnect/crash detected in etltv.log -> full restart"
  stop_tw >/dev/null 2>&1 || true
  stop_etl >/dev/null 2>&1 || true
  sleep 3
  start_etl >/dev/null 2>&1 || log "ETL restart after disconnect failed"
  start_tw  >/dev/null 2>&1 || log "Stream restart after disconnect failed"
}

supervise_loop() {
  log "supervise loop entered"

  while true; do
    # 1) log-based disconnect/crash detection
    if etl_log_has_disconnect; then
      full_restart_due_disconnect
      sleep "$CHECK_INTERVAL"
      continue
    fi

    # 2) ETL process check
    if ! running_from_pidfile "$ETL_PIDFILE"; then
      log "ETL not running -> restarting"
      start_etl >/dev/null 2>&1 || log "ETL restart failed"

      # after ETL restart, restart stream too (with warmup)
      if running_from_pidfile "$TW_PIDFILE"; then
        stop_tw >/dev/null 2>&1 || true
      fi
      start_tw >/dev/null 2>&1 || log "Stream restart after ETL failed"
    fi

    # 3) Stream process check
    if ! running_from_pidfile "$TW_PIDFILE"; then
      log "Stream not running -> restarting"
      start_tw >/dev/null 2>&1 || log "Stream restart failed"
    fi

    sleep "$CHECK_INTERVAL"
  done
}

case "${1:-}" in
  start)
    start_etl
    start_tw
    start_supervisor
    ;;
  stop)
    stop_supervisor
    stop_tw
    stop_etl
    ;;
  restart)
    stop_supervisor
    stop_tw
    stop_etl
    sleep 3
    start_etl
    start_tw
    start_supervisor
    ;;
  status)
    status_all
    ;;
  supervise)
    supervise_loop
    ;;
  *)
    echo "Usage: $0 {start|stop|restart|status}"
    exit 1
    ;;
esac
8. Step 5: Start, stop & restart

Run all commands as user tvbot:

cd /home/tvbot/etlegacy

# start ETL + stream + supervisor
./tvbot_supervisor.sh start

# show status
./tvbot_supervisor.sh status

# restart everything
./tvbot_supervisor.sh restart

# stop everything
./tvbot_supervisor.sh stop
Optional: You can add a cronjob for a clean restart once per day (for example at 05:52):

crontab -e (as tvbot) and add:
52 5 * * * /home/tvbot/etlegacy/tvbot_supervisor.sh restart >/dev/null 2>&1
9. Troubleshooting

9.1 ETL stuck on "LOADING..."

  • Check /home/tvbot/etlegacy/etltv.log for the last lines.
  • Ensure your config does not contain an incomplete exec line (everything after a // comment is ignored).
  • Make sure the TV-Bot profile uses sane video settings and a valid resolution.

9.2 Multiple TV clients starting

  • Check that etl.sh writes the PID to etltv.pid and does not start twice.
  • Make sure you did not copy etl.sh elsewhere and call both by accident.

9.3 Stream is running, but Twitch shows "offline"

  • Confirm the TWITCH_KEY is correct and not revoked.
  • Check twitch_stream.log for ffmpeg errors (codec not found, network issues, etc.).
  • Make sure your firewall allows outgoing RTMP to Twitch.

9.4 Supervisor spams restarts

  • Check tvbot_supervisor.log to see why it restarts.
  • Inspect patterns in etl_log_has_disconnect() – if your ETL log contains similar text during normal operation, reduce the regex or increase DISCONNECT_COOLDOWN.