24x7 RTMP desktop stream, client side + UAT PASS (#798)

Whole-screen KDE capture -> x264 -> RTMP, fenced to the rt slice.
Loopback mediamtx receiver; Cloudron cutover = one creds edit.
Detail: https://projects.knownelement.com/issues/798#note-4519

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
2026-09-05 05:01:23 -05:00
parent f4e36ec647
commit 777767db54
15 changed files with 503 additions and 5 deletions
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# ukrrs-rtmp-stream.sh — 24x7 RTMP stream of the reachableceo KDE desktop (#798)
#
# Runs as User=reachableceo under ukrrs-rtmp-stream.service (Slice=ukrrs-rt.slice,
# pinned to cores 6-7 so it can never starve crush/gateway/LSPs on 8 vCPU).
# Approved host-side ffmpeg (7.1.5, pre-installed) per #798 ruling 2026-09-05:
# same bare-metal exception family as open-terminal (#610).
#
# What it does, forever:
# 1. Find the NEWEST xorgxrdp X server owned by STREAM_USER (xrdp display
# numbers move around; after reboot there is no session until the human
# RDPs in — the loop just waits).
# 2. Read its current geometry (xrandr) — survives iPad-vs-monitor res flips
# by restarting ffmpeg with the new size.
# 3. Grab the whole screen (x11grab) + desktop audio if a pulse monitor
# source exists, scale to OUT_WIDTH, x264-encode, push FLV to RTMP_URL.
# 4. Watchdog every 15s: resolution change or dead ffmpeg -> clean restart;
# dead session -> wait for the next one.
#
# Config:
# /etc/ukrrs/rtmp-stream.env tunables (fps/res/bitrate/preset/user)
# ~/.creds/rtmp-stream.env RTMP_URL (SECRET when it carries a stream
# key; the ONLY place it lives, 0600)
#
# Install path: /usr/local/sbin/ukrrs-rtmp-stream.sh (via 18-rtmp-stream.sh).
# Manual test: CREDS_ENV=$PWD/rtmp/uat-creds.env ./staged/ukrrs-rtmp-stream.sh
set -u
CONF=/etc/ukrrs/rtmp-stream.env
CREDS_ENV=${CREDS_ENV:-/home/reachableceo/.creds/rtmp-stream.env}
[ -r "$CONF" ] && . "$CONF"
[ -r "$CREDS_ENV" ] && . "$CREDS_ENV"
STREAM_USER=${STREAM_USER:-reachableceo}
FPS=${FPS:-30}
OUT_WIDTH=${OUT_WIDTH:-1920}
BITRATE=${BITRATE:-6000k}
MAXRATE=${MAXRATE:-9000k}
BUFSIZE=${BUFSIZE:-12000k}
PRESET=${PRESET:-superfast}
ABITRATE=${ABITRATE:-128k}
LOGLEVEL=${LOGLEVEL:-warning}
say() { echo "[$(date '+%F %T')][rtmp-stream] $*"; }
TUID=$(id -u "$STREAM_USER") || { say "FATAL: no user $STREAM_USER"; exit 1; }
THOME=$(getent passwd "$STREAM_USER" | cut -d: -f6)
XAUTH="$THOME/.Xauthority"
# ---- discovery -------------------------------------------------------------
find_display() { # echoes ":N" of newest xorgxrdp X server owned by TUID
local best=0 best_disp="" pid start cmd disp
for pid in $(pgrep -u "$TUID" -x Xorg 2>/dev/null); do
cmd=$(tr '\0' ' ' </proc/"$pid"/cmdline 2>/dev/null) || continue
case "$cmd" in *"-config xrdp/xorg.conf"*) ;; *) continue ;; esac
disp=$(echo "$cmd" | grep -oE ':[0-9]+' | head -1)
[ -n "$disp" ] || continue
start=$(awk '{print $22}' /proc/"$pid"/stat 2>/dev/null) || continue
if [ "${start:-0}" -gt "$best" ]; then best=$start; best_disp=$disp; fi
done
echo "$best_disp"
}
get_geom() { # $1=display -> echoes "WxH" (empty on failure)
local g
g=$(DISPLAY="$1" XAUTHORITY="$XAUTH" timeout 10 xrandr --current 2>/dev/null \
| grep -m1 -oE 'current [0-9]+ x [0-9]+' | grep -oE '[0-9]+ x [0-9]+' | tr -d ' ')
echo "$g"
}
get_audio_src() { # echoes pulse monitor source name, empty = stream without audio
command -v pactl >/dev/null 2>&1 || return 0
timeout 10 pactl info >/dev/null 2>&1 || return 0
timeout 10 pactl list short sources 2>/dev/null \
| awk '$2 ~ /\.monitor$/ && $2 !~ /auto_null/ {print $2; exit}
$2 ~ /\.monitor$/ && !f {s=$2; f=1} END {if (!NR) exit; print s}'
}
FFPID=0
cleanup() { [ "$FFPID" -gt 0 ] && kill "$FFPID" 2>/dev/null; wait 2>/dev/null; exit 0; }
trap 'cleanup' TERM INT
# ---- main loop -------------------------------------------------------------
say "started (user=$STREAM_USER uid=$TUID out=${OUT_WIDTH}p fps=$FPS preset=$PRESET)"
while :; do
if [ -z "${RTMP_URL:-}" ]; then
say "FATAL: RTMP_URL unset in $CREDS_ENV; retrying in 30s"
sleep 30; continue
fi
DISP=$(find_display)
if [ -z "$DISP" ]; then
say "no $STREAM_USER desktop session yet; waiting 10s"
sleep 10; continue
fi
GEOM=$(get_geom "$DISP")
if [ -z "$GEOM" ]; then say "display $DISP found but no RandR geometry yet; waiting 5s"; sleep 5; continue; fi
ASRC=$(get_audio_src)
VARGS=(-nostdin -hide_banner -loglevel "$LOGLEVEL" -stats
-f x11grab -framerate "$FPS" -video_size "$GEOM" -draw_mouse 1
-i "$DISP.0")
if [ -n "$ASRC" ]; then
VARGS+=(-f pulse -thread_queue_size 512 -i "$ASRC")
fi
VARGS+=(-vf "scale=${OUT_WIDTH}:-2:flags=bicubic,format=yuv420p"
-c:v libx264 -preset "$PRESET" -profile:v high
-g $((FPS * 2)) -bf 2 -sc_threshold 0
-b:v "$BITRATE" -maxrate "$MAXRATE" -bufsize "$BUFSIZE")
if [ -n "$ASRC" ]; then
VARGS+=(-c:a aac -b:a "$ABITRATE" -ar 48000)
else
VARGS+=(-an)
fi
VARGS+=(-flvflags no_duration_filesize -f flv "$RTMP_URL")
say "LIVE: display $DISP ${GEOM} -> ${OUT_WIDTH}p@$FPS ${BITRATE} audio=${ASRC:-none} -> $RTMP_URL"
ffmpeg "${VARGS[@]}" &
FFPID=$!
# watchdog: exit if ffmpeg dies, session dies, or geometry changes
while kill -0 "$FFPID" 2>/dev/null; do
sleep 15
kill -0 "$FFPID" 2>/dev/null || break
if [ -z "$(find_display)" ] || [ "$(find_display)" != "$DISP" ]; then
say "session changed/gone ($DISP); restarting capture"; break
fi
CUR=$(get_geom "$DISP")
if [ "$CUR" != "$GEOM" ] && [ -n "$CUR" ]; then
say "resolution changed ${GEOM} -> ${CUR}; restarting encoder"; break
fi
done
kill "$FFPID" 2>/dev/null
wait "$FFPID" 2>/dev/null
RC=$?
FFPID=0
say "ffmpeg exited rc=$RC; backing off 5s"
sleep 5
done