Last active
April 3, 2025 21:57
-
-
Save Cdaprod/57cf73d8773fadaa2f6b8aaf7a0c893d to your computer and use it in GitHub Desktop.
My Linux live-streaming command—“Secret-Weapon Grade” Alias
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env bash | |
# sudo tee /dev/null <<'EOF' | |
# ============================================================================== | |
# Streamctl - Wayland/Wlroots Display Streaming Tool (Hardened, Parallel-Enabled) | |
# | |
# A versatile, fault-tolerant streaming control utility with support for: | |
# • HLS, MJPEG, RTSP streaming modes with graceful fallback | |
# • Custom streaming profiles (e.g. YouTube with your stream key) | |
# • OBS WebSocket configuration (port 4455) for eventual integration | |
# • Desktop recording mode | |
# • Live system stats, self-healing, and redundant failover | |
# • Parallel execution where beneficial (using GNU parallel if available) | |
# | |
# Created by: David Cannan | |
# GitHub: https://github.com/Cdaprod | |
# Email: [email protected] | |
# | |
# Save to /usr/local/bin/streamctl and run: chmod +x /usr/local/bin/streamctl | |
# ============================================================================== | |
# Colors for terminal output | |
RED='\033[0;31m' | |
GREEN='\033[0;32m' | |
YELLOW='\033[0;33m' | |
BLUE='\033[0;34m' | |
CYAN='\033[0;36m' | |
NC='\033[0m' # No Color | |
BOLD='\033[1m' | |
# Environment & Configuration | |
export XDG_RUNTIME_DIR=/run/user/1000 | |
export WAYLAND_DISPLAY=wayland-0 | |
export DISPLAY=:0 | |
SESSION="wfstream" | |
OUTPUT_MODE="${2:-hls}" # default mode if not specified | |
HLS_DIR="/dev/shm/hls" | |
HLS_PORT="8080" | |
STREAM_PORT="8090" | |
RTSP_PORT="8554" | |
LOG_FILE="/tmp/streamctl.log" | |
FIFO="/tmp/wf-stream.fifo" | |
PID_FILE="/tmp/streamctl.pid" | |
# OBS WebSocket configuration (future integration) | |
OBS_WEBSOCKET_PORT="4455" | |
# YouTube Streaming (update your key here or via a profile) | |
YOUTUBE_KEY="abcd-1234-xyz9-wxyz" | |
YOUTUBE_URL="rtmp://a.rtmp.youtube.com/live2/${YOUTUBE_KEY}" | |
# ----------------------------------------------------------------------------- | |
# Logging functions | |
log() { echo -e "${CYAN}[$(date +"%Y-%m-%d %H:%M:%S")]${NC} $1" | tee -a "$LOG_FILE"; } | |
error() { echo -e "${RED}[ERROR]${NC} $1" | tee -a "$LOG_FILE"; exit 1; } | |
warning(){ echo -e "${YELLOW}[WARNING]${NC} $1" | tee -a "$LOG_FILE"; } | |
success(){ echo -e "${GREEN}[SUCCESS]${NC} $1"; } | |
# ----------------------------------------------------------------------------- | |
# Fallback display detection: try wlr-randr, then sway, then waybar | |
get_display_id() { | |
local try_order=("wlr-randr" "sway" "waybar") | |
local DISPLAY_ID="" | |
for method in "${try_order[@]}"; do | |
case "$method" in | |
wlr-randr) | |
if command -v wlr-randr &>/dev/null; then | |
DISPLAY_ID=$(wlr-randr | awk '/^[^ ]/ {id=$1} /Enabled: yes/ {print id}' | head -n1) | |
[[ -n "$DISPLAY_ID" ]] && break | |
fi | |
;; | |
sway) | |
if command -v sway &>/dev/null; then | |
DISPLAY_ID=$(sway -t get_outputs | grep name | head -n1 | awk '{print $2}' | tr -d '"') | |
[[ -n "$DISPLAY_ID" ]] && break | |
fi | |
;; | |
waybar) | |
if command -v waybar &>/dev/null; then | |
DISPLAY_ID=$(waybar -l | grep "Output:" | head -n1 | awk '{print $2}') | |
[[ -n "$DISPLAY_ID" ]] && break | |
fi | |
;; | |
esac | |
done | |
if [[ -z "$DISPLAY_ID" ]]; then | |
error "No valid display output found after trying: ${try_order[*]}" | |
fi | |
echo "$DISPLAY_ID" | |
} | |
# ----------------------------------------------------------------------------- | |
# Create FIFO with safe retries | |
ensure_fifo() { | |
local attempt=1 | |
local max_attempts=3 | |
while [[ $attempt -le $max_attempts ]]; do | |
if [[ -p "$FIFO" ]]; then | |
rm -f "$FIFO" | |
sleep 0.5 | |
fi | |
mkfifo "$FIFO" && { log "FIFO created at $FIFO"; return 0; } | |
warning "Attempt $attempt to create FIFO failed." | |
attempt=$((attempt + 1)) | |
sleep 1 | |
done | |
error "Failed to create FIFO after $max_attempts attempts." | |
} | |
# ----------------------------------------------------------------------------- | |
# Check for required commands; return first found from a list | |
safe_command() { | |
for cmd in "$@"; do | |
if command -v "$cmd" &>/dev/null; then | |
echo "$cmd" && return 0 | |
fi | |
done | |
return 1 | |
} | |
# ----------------------------------------------------------------------------- | |
# Kill process if alive based on a PID file | |
kill_pid_if_alive() { | |
local pidfile="$1" | |
if [[ -f "$pidfile" ]]; then | |
local pid | |
pid=$(<"$pidfile") | |
if kill -0 "$pid" 2>/dev/null; then | |
kill "$pid" 2>/dev/null | |
log "Killed process $pid from $pidfile" | |
fi | |
rm -f "$pidfile" | |
fi | |
} | |
# ----------------------------------------------------------------------------- | |
# Load streaming profile if provided (profiles stored in ~/.config/streamctl/profiles/) | |
load_profile() { | |
local profile="$1" | |
local profile_file="$HOME/.config/streamctl/profiles/${profile}.profile" | |
if [ -f "$profile_file" ]; then | |
source "$profile_file" | |
log "Loaded profile: $profile" | |
else | |
warning "Profile not found: $profile. Using defaults." | |
fi | |
} | |
# ----------------------------------------------------------------------------- | |
# Start streaming modes | |
start_hls() { | |
mkdir -p "$HLS_DIR" || error "Failed to create HLS directory: $HLS_DIR" | |
ensure_fifo | |
local ip_address | |
ip_address=$(hostname -I | awk '{print $1}') | |
log "Starting HLS stream: http://${ip_address}:${HLS_PORT}/stream.m3u8" | |
tmux new-session -d -s "$SESSION" bash -c " | |
$(safe_command wf-recorder) -o \"\$DISPLAY_ID\" -m matroska -f \"$FIFO\" 2>&1 | tee -a \"${LOG_FILE}\" & | |
echo \$! > \"${PID_FILE}.recorder\" | |
ffmpeg -nostdin -y -f matroska -i \"$FIFO\" \ | |
-c:v libx264 -preset ultrafast -b:v ${BITRATE:-4500k} -maxrate ${BITRATE:-4500k} -bufsize 9000k -g 60 \ | |
-f hls -hls_time 2 -hls_list_size 5 -hls_flags delete_segments \ | |
-hls_segment_filename \"${HLS_DIR}/stream_%03d.ts\" \"${HLS_DIR}/stream.m3u8\" 2>&1 | tee -a \"${LOG_FILE}\" & | |
echo \$! > \"${PID_FILE}.ffmpeg\" | |
wait | |
" && return 0 | |
return 1 | |
} | |
start_mjpeg() { | |
ensure_fifo | |
local ip_address | |
ip_address=$(hostname -I | awk '{print $1}') | |
log "Starting MJPEG stream: http://${ip_address}:${STREAM_PORT}/" | |
tmux new-session -d -s "$SESSION" bash -c " | |
$(safe_command wf-recorder) -o \"\$DISPLAY_ID\" -m matroska -f \"$FIFO\" 2>&1 | tee -a \"${LOG_FILE}\" & | |
echo \$! > \"${PID_FILE}.recorder\" | |
ffmpeg -nostdin -y -f matroska -i \"$FIFO\" \ | |
-c:v mjpeg -f mjpeg -q:v 5 http://0.0.0.0:${STREAM_PORT}/ 2>&1 | tee -a \"${LOG_FILE}\" & | |
echo \$! > \"${PID_FILE}.ffmpeg\" | |
wait | |
" && return 0 | |
return 1 | |
} | |
start_rtsp() { | |
ensure_fifo | |
local ip_address | |
ip_address=$(hostname -I | awk '{print $1}') | |
log "Starting RTSP stream: rtsp://${ip_address}:${RTSP_PORT}/live.stream" | |
tmux new-session -d -s "$SESSION" bash -c " | |
$(safe_command wf-recorder) -o \"\$DISPLAY_ID\" -m matroska -f \"$FIFO\" 2>&1 | tee -a \"${LOG_FILE}\" & | |
echo \$! > \"${PID_FILE}.recorder\" | |
ffmpeg -nostdin -y -f matroska -i \"$FIFO\" \ | |
-c:v libx264 -preset ultrafast -tune zerolatency \ | |
-f rtsp rtsp://0.0.0.0:${RTSP_PORT}/live.stream 2>&1 | tee -a \"${LOG_FILE}\" & | |
echo \$! > \"${PID_FILE}.ffmpeg\" | |
wait | |
" && return 0 | |
return 1 | |
} | |
# ----------------------------------------------------------------------------- | |
# Fallback streaming: try HLS, then MJPEG, then RTSP | |
start_stream_with_fallback() { | |
local modes=("hls" "mjpeg" "rtsp") | |
local success=0 | |
for mode in "${modes[@]}"; do | |
log "Attempting stream mode: $mode" | |
case "$mode" in | |
hls) start_hls && { success=1; break; } ;; | |
mjpeg) start_mjpeg && { success=1; break; } ;; | |
rtsp) start_rtsp && { success=1; break; } ;; | |
esac | |
warning "Stream mode $mode failed. Trying next..." | |
done | |
[[ $success -eq 1 ]] || error "All stream modes failed." | |
} | |
# ----------------------------------------------------------------------------- | |
# Desktop recording mode | |
record_desktop() { | |
local output_file="${HOME}/Videos/streamctl_recording_$(date +%Y%m%d_%H%M%S).mkv" | |
log "Recording desktop to: $output_file" | |
$(safe_command wf-recorder) -o "$DISPLAY_ID" -f "$output_file" | |
} | |
# ----------------------------------------------------------------------------- | |
# Stop streaming and clean up resources, using parallel where possible | |
stop_stream() { | |
# Kill tmux sessions concurrently (background them) | |
if tmux has-session -t "$SESSION" 2>/dev/null; then | |
tmux kill-session -t "$SESSION" 2>/dev/null & | |
log "Terminated streaming session: $SESSION" | |
fi | |
if tmux has-session -t "${SESSION}_http" 2>/dev/null; then | |
tmux kill-session -t "${SESSION}_http" 2>/dev/null & | |
log "Terminated HTTP session." | |
fi | |
wait | |
# Use GNU parallel to kill PID files concurrently if available | |
if command -v parallel &>/dev/null; then | |
echo "${PID_FILE}.recorder ${PID_FILE}.ffmpeg ${PID_FILE}.http" | tr ' ' '\n' | parallel kill_pid_if_alive {} | |
else | |
for pid_file in "${PID_FILE}.recorder" "${PID_FILE}.ffmpeg" "${PID_FILE}.http"; do | |
kill_pid_if_alive "$pid_file" & | |
done | |
wait | |
fi | |
# Remove FIFO if exists | |
[[ -p "$FIFO" ]] && { rm -f "$FIFO"; log "FIFO removed."; } | |
# Clean HLS files concurrently | |
if [ -d "$HLS_DIR" ]; then | |
rm -rf "${HLS_DIR:?}"/*.ts "${HLS_DIR:?}"/*.m3u8 2>/dev/null & | |
wait | |
log "Cleaned up HLS files." | |
fi | |
success "Stream stopped and resources cleaned up." | |
} | |
# ----------------------------------------------------------------------------- | |
# Check stream status and system stats | |
check_status() { | |
echo -e "${BOLD}${CYAN}=== Streamctl Status ===${NC}" | |
if tmux has-session -t "$SESSION" 2>/dev/null; then | |
echo -e "✅ ${GREEN}Streaming session ($SESSION) is running.${NC}" | |
else | |
echo -e "❌ ${RED}No active streaming session.${NC}" | |
fi | |
if tmux has-session -t "${SESSION}_http" 2>/dev/null; then | |
echo -e "✅ ${GREEN}HTTP server is running on port ${HLS_PORT}.${NC}" | |
else | |
echo -e "❌ ${RED}HTTP server is not running.${NC}" | |
fi | |
# Show PID status | |
for pid_file in "${PID_FILE}.recorder" "${PID_FILE}.ffmpeg" "${PID_FILE}.http"; do | |
if [ -f "$pid_file" ]; then | |
pid=$(<"$pid_file") | |
if kill -0 "$pid" 2>/dev/null; then | |
echo -e "✅ ${GREEN}Process running (PID: ${pid}).${NC}" | |
fi | |
fi | |
done | |
# System resource stats | |
echo -e "${BOLD}${CYAN}=== System Stats ===${NC}" | |
echo -e "${BOLD}CPU:${NC} $(top -bn1 | grep "Cpu(s)" | awk '{print $2 + $4 "%"}')" | |
echo -e "${BOLD}Memory:${NC} $(free -h | awk '/Mem:/ {print $3 "/" $2}')" | |
echo -e "${BOLD}Disk:${NC} $(df -h / | awk 'NR==2 {print $3 "/" $2}')" | |
local ip_address | |
ip_address=$(hostname -I | awk '{print $1}') | |
case "$OUTPUT_MODE" in | |
hls) echo -e "${YELLOW}Stream URL:${NC} http://${ip_address}:${HLS_PORT}/stream.m3u8" ;; | |
mjpeg) echo -e "${YELLOW}Stream URL:${NC} http://${ip_address}:${STREAM_PORT}/" ;; | |
rtsp) echo -e "${YELLOW}Stream URL:${NC} rtsp://${ip_address}:${RTSP_PORT}/live.stream" ;; | |
esac | |
echo -e "${BOLD}${CYAN}OBS WebSocket:${NC} Configured on port ${OBS_WEBSOCKET_PORT}" | |
echo -e "${BOLD}YouTube URL:${NC} ${YOUTUBE_URL}" | |
echo -e "${BOLD}Cooked by:${NC} ${BOLD}David Cannan (Cdaprod)${NC}" | |
} | |
# ----------------------------------------------------------------------------- | |
# Self-healing: if no streaming session is found, try to restart | |
self_heal() { | |
if ! tmux has-session -t "$SESSION" 2>/dev/null; then | |
log "No streaming session detected. Initiating self-heal..." | |
start_stream_with_fallback | |
else | |
log "Streaming session is active." | |
fi | |
} | |
# ----------------------------------------------------------------------------- | |
# Retry logic for starting stream (if --retry flag is provided) | |
retry_start() { | |
local retries=5 | |
for ((i = 1; i <= retries; i++)); do | |
log "Attempt $i to start stream..." | |
if start_hls; then | |
log "Stream started successfully on attempt $i." | |
return 0 | |
fi | |
sleep 2 | |
done | |
error "All $retries attempts to start the stream failed." | |
} | |
# ----------------------------------------------------------------------------- | |
# Cleanup handler: ensure resources are freed on exit | |
cleanup() { | |
log "Cleaning up before exit..." | |
stop_stream | |
} | |
trap cleanup EXIT INT TERM | |
# ----------------------------------------------------------------------------- | |
# Main command-line argument parsing | |
case "$1" in | |
start) | |
# Optionally load a profile if provided as third parameter (e.g., 'youtube') | |
[ -n "$3" ] && load_profile "$3" | |
# Use --retry flag if provided | |
if [[ "$2" == "--retry" ]]; then | |
retry_start | |
else | |
start_stream_with_fallback | |
fi | |
;; | |
stop) | |
stop_stream | |
;; | |
status) | |
check_status | |
;; | |
record) | |
record_desktop | |
;; | |
attach) | |
if ! tmux has-session -t "$SESSION" 2>/dev/null; then | |
error "No streaming session to attach to. Start one with 'streamctl start'." | |
fi | |
tmux attach -t "$SESSION" | |
;; | |
log) | |
if [ ! -f "$LOG_FILE" ]; then | |
error "Log file not found: $LOG_FILE" | |
fi | |
tail -f "$LOG_FILE" | |
;; | |
selfheal) | |
self_heal | |
;; | |
help|*) | |
echo -e "${BOLD}${CYAN}=== Streamctl Help ===${NC}" | |
echo -e "${BOLD}Usage:${NC}" | |
echo -e " streamctl ${BOLD}start${NC} [--retry] [profile] Start streaming (optionally retry, and load a profile)" | |
echo -e " streamctl ${BOLD}stop${NC} Stop streaming and clean up" | |
echo -e " streamctl ${BOLD}status${NC} Show streaming status and system stats" | |
echo -e " streamctl ${BOLD}record${NC} Record desktop to a file" | |
echo -e " streamctl ${BOLD}attach${NC} Attach to the streaming tmux session" | |
echo -e " streamctl ${BOLD}log${NC} Tail the stream log" | |
echo -e " streamctl ${BOLD}selfheal${NC} Self-heal: restart stream if not running" | |
echo -e " streamctl ${BOLD}help${NC} Show this help message" | |
echo "" | |
echo -e "${BOLD}Additional Config:${NC}" | |
echo -e " OBS WebSocket configured on port ${OBS_WEBSOCKET_PORT}" | |
echo -e " YouTube URL: ${YOUTUBE_URL}" | |
exit 1 | |
;; | |
esac | |
# EOF (sudo tee /dev/null <<'EOF' block end) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment