#!/bin/bash ############################################### # Script: stream-recorder # Description: Generischer Stream-Recorder für RTMP, HLS, # HTTP-Audio (MP3, AAC, OGG) und weitere Formate # Platforms: Debian/Ubuntu # Author: Patrick Asmus # Web: https://www.cleveradmin.de # Repository: https://git.techniverse.net/scriptos/stream-recorder.git # License: MIT # Version: v2.3.0 # Date: 06.09.2026 # Modifications: Grundlegende Überarbeitung + viele neue Funktionen implementiert ############################################### set -uo pipefail readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly VERSION="2.3.0" # --- Standardwerte (überschreibbar via Konfiguration) --- CONFIG_FILE="${SCRIPT_DIR}/config/stream-recorder.conf" JOBS_DIR="${SCRIPT_DIR}/jobs" DOWNLOAD_PATH="/home/downloads/recordings" LOG_DIR="/var/log/stream-recorder" LOG_LEVEL="INFO" PID_DIR="/tmp/stream-recorder" MAX_RETRIES=0 RETRY_DELAY=5 # --- NTFY-Benachrichtigungen (optional) --- NTFY_URL="" NTFY_TOKEN="" NTFY_EVENTS="error" # --- Plik-Upload (optional) --- PLIK_URL="" PLIK_API_KEY="" PLIK_TTL="30d" # --- Laufzeit-Flags --- VERBOSE=false # ============================================= # Hilfsfunktionen # ============================================= _log_level_num() { case "$1" in DEBUG) echo 0 ;; INFO) echo 1 ;; WARN) echo 2 ;; ERROR) echo 3 ;; *) echo 1 ;; esac } log() { local level="$1" shift local message="$*" local msg_num cur_num msg_num=$(_log_level_num "$level") cur_num=$(_log_level_num "$LOG_LEVEL") [[ $msg_num -lt $cur_num ]] && return local timestamp timestamp=$(date '+%Y-%m-%d %H:%M:%S') local entry="[${timestamp}] [${level}] ${message}" mkdir -p "$LOG_DIR" 2>/dev/null || true echo "$entry" >> "${LOG_DIR}/stream-recorder.log" 2>/dev/null || true if [[ -t 1 ]] || [[ "$VERBOSE" == "true" ]]; then echo "$entry" fi } notify() { local event="$1" local title="$2" local message="$3" local priority="${4:-default}" [[ -z "${NTFY_URL:-}" ]] && return command -v curl &>/dev/null || return [[ ! ",${NTFY_EVENTS}," == *",${event},"* ]] && return local -a curl_args=(-s -o /dev/null --max-time 10) curl_args+=(-H "Title: ${title}") curl_args+=(-H "Priority: ${priority}") curl_args+=(-H "Tags: stream-recorder,${event}") if [[ -n "${NTFY_TOKEN:-}" ]]; then curl_args+=(-H "Authorization: Bearer ${NTFY_TOKEN}") fi curl_args+=(-d "$message") curl_args+=("$NTFY_URL") curl "${curl_args[@]}" 2>/dev/null & disown 2>/dev/null || true } upload_to_plik() { local file="$1" local stream_name="$2" [[ "${PLIK_ENABLED:-false}" != "true" ]] && return [[ -z "${PLIK_URL:-}" ]] && return [[ ! -f "$file" ]] && return command -v curl &>/dev/null || { log "WARN" "curl nicht installiert - Plik-Upload übersprungen"; return; } local filename filename=$(basename "$file") local plik_base="${PLIK_URL%/}" local -a curl_args=(-s --max-time 300) curl_args+=(-X POST) if [[ -n "${PLIK_API_KEY:-}" ]]; then curl_args+=(-H "X-PlikToken: ${PLIK_API_KEY}") fi local ttl_seconds="" if [[ -n "${PLIK_TTL:-}" ]]; then local ttl_val="${PLIK_TTL}" if [[ "$ttl_val" =~ ^([0-9]+)d$ ]]; then ttl_seconds=$(( ${BASH_REMATCH[1]} * 86400 )) elif [[ "$ttl_val" =~ ^([0-9]+)h$ ]]; then ttl_seconds=$(( ${BASH_REMATCH[1]} * 3600 )) elif [[ "$ttl_val" =~ ^[0-9]+$ ]]; then ttl_seconds="$ttl_val" fi fi log "INFO" "Plik-Upload: ${filename} -> ${plik_base}" local create_json="{\"files\":[{\"fileName\":\"${filename}\"}]" if [[ -n "$ttl_seconds" ]]; then create_json+=",\"ttl\":${ttl_seconds}" fi create_json+="}" local create_response create_response=$(curl "${curl_args[@]}" \ -H "Content-Type: application/json" \ -d "$create_json" \ "${plik_base}/upload" 2>/dev/null) if [[ -z "$create_response" ]]; then log "ERROR" "Plik-Upload fehlgeschlagen: Keine Antwort vom Server" return 1 fi local upload_id file_id upload_id=$(echo "$create_response" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) file_id=$(echo "$create_response" | grep -o '"id":"[^"]*"' | tail -1 | cut -d'"' -f4) if [[ -z "$upload_id" || -z "$file_id" ]]; then log "ERROR" "Plik-Upload fehlgeschlagen: Upload konnte nicht erstellt werden" log "DEBUG" "Plik-Antwort: ${create_response}" return 1 fi local -a upload_curl_args=(-s --max-time 600) if [[ -n "${PLIK_API_KEY:-}" ]]; then upload_curl_args+=(-H "X-PlikToken: ${PLIK_API_KEY}") fi local upload_response upload_response=$(curl "${upload_curl_args[@]}" \ -X POST \ -T "$file" \ "${plik_base}/file/${upload_id}/${file_id}/${filename}" 2>/dev/null) local download_url="${plik_base}/file/${upload_id}/${file_id}/${filename}" log "INFO" "Plik-Upload abgeschlossen: ${download_url}" notify "upload" "Plik-Upload" "${stream_name}: ${download_url}" echo "$download_url" } parse_duration() { local input="$1" [[ -z "$input" ]] && { echo ""; return; } [[ "$input" =~ ^[0-9]+$ ]] && { echo "$input"; return; } local total=0 [[ "$input" =~ ([0-9]+)h ]] && total=$((total + ${BASH_REMATCH[1]} * 3600)) [[ "$input" =~ ([0-9]+)m ]] && total=$((total + ${BASH_REMATCH[1]} * 60)) [[ "$input" =~ ([0-9]+)s ]] && total=$((total + ${BASH_REMATCH[1]})) if [[ $total -eq 0 ]]; then log "WARN" "Ungültige Dauer: ${input}" echo "" return fi echo "$total" } # --- Tages-/Zeitplanung --- _day_name_to_num() { case "${1,,}" in mo|mon|montag|1) echo 1 ;; di|tue|dienstag|2) echo 2 ;; mi|wed|mittwoch|3) echo 3 ;; do|thu|donnerstag|4) echo 4 ;; fr|fri|freitag|5) echo 5 ;; sa|sat|samstag|6) echo 6 ;; so|sun|sonntag|7) echo 7 ;; *) echo 0 ;; esac } _matches_schedule_day() { local today_num="$1" local schedule="$2" [[ "$schedule" == "*" ]] && return 0 local IFS=',' for entry in $schedule; do entry=$(echo "$entry" | tr -d ' ') if [[ "$entry" == *-* ]]; then local start_num end_num start_num=$(_day_name_to_num "${entry%%-*}") end_num=$(_day_name_to_num "${entry##*-}") if [[ $start_num -le $end_num ]]; then [[ $today_num -ge $start_num && $today_num -le $end_num ]] && return 0 else [[ $today_num -ge $start_num || $today_num -le $end_num ]] && return 0 fi else local day_num day_num=$(_day_name_to_num "$entry") [[ $today_num -eq $day_num ]] && return 0 fi done return 1 } _time_to_minutes() { local time="$1" local hours="${time%%:*}" local minutes="${time##*:}" echo $(( 10#$hours * 60 + 10#$minutes )) } _in_time_window() { local now="$1" start="$2" stop="$3" local now_min start_min stop_min now_min=$(_time_to_minutes "$now") start_min=$(_time_to_minutes "$start") stop_min=$(_time_to_minutes "$stop") if [[ $start_min -le $stop_min ]]; then [[ $now_min -ge $start_min && $now_min -lt $stop_min ]] else [[ $now_min -ge $start_min || $now_min -lt $stop_min ]] fi } # ============================================= # Konfiguration & Abhängigkeiten # ============================================= load_config() { if [[ -f "$CONFIG_FILE" ]]; then # shellcheck source=/dev/null source "$CONFIG_FILE" else log "WARN" "Konfigurationsdatei nicht gefunden: ${CONFIG_FILE} - verwende Standardwerte" fi mkdir -p "$DOWNLOAD_PATH" "$LOG_DIR" "$PID_DIR" 2>/dev/null || true } check_dependencies() { if ! command -v ffmpeg &>/dev/null; then log "ERROR" "ffmpeg ist nicht installiert" log "INFO" "Installation: sudo apt install ffmpeg" exit 1 fi if [[ -n "${NTFY_URL:-}" ]] && ! command -v curl &>/dev/null; then log "WARN" "curl ist nicht installiert - NTFY-Benachrichtigungen deaktiviert" fi } # ============================================= # Stream-Erkennung & Job-Verwaltung # ============================================= detect_stream_type() { local url="$1" case "$url" in rtmp://*|rtmps://*) echo "rtmp" ;; *.[Mm]3[Uu]8|*.[Mm]3[Uu]8\?*) echo "hls" ;; *.[Mm][Pp]3|*.[Mm][Pp]3\?*) echo "mp3" ;; *.[Aa][Aa][Cc]|*.[Aa][Aa][Cc]\?*) echo "aac" ;; *.[Oo][Gg][Gg]|*.[Oo][Gg][Gg]\?*) echo "ogg" ;; *.[Ff][Ll][Aa][Cc]|*.[Ff][Ll][Aa][Cc]\?*) echo "flac" ;; http://*|https://*) echo "http" ;; *) echo "http" ;; esac } default_extension() { case "$1" in rtmp|hls|http) echo "mp4" ;; mp3) echo "mp3" ;; aac) echo "aac" ;; ogg) echo "ogg" ;; flac) echo "flac" ;; *) echo "mp4" ;; esac } load_job() { local job_file="$1" STREAM_NAME="" STREAM_URL="" STREAM_TYPE="auto" OUTPUT_FORMAT="" MAX_DURATION="" EXTRA_FFMPEG_ARGS="" SCHEDULE_ENABLED="false" SCHEDULE_DAYS="*" SCHEDULE_START="" SCHEDULE_STOP="" PLIK_ENABLED="false" if [[ ! -f "$job_file" ]]; then log "ERROR" "Job-Datei nicht gefunden: ${job_file}" return 1 fi # shellcheck source=/dev/null source "$job_file" if [[ -z "$STREAM_NAME" ]]; then log "ERROR" "STREAM_NAME fehlt in: ${job_file}" return 1 fi if [[ -z "$STREAM_URL" ]]; then log "ERROR" "STREAM_URL fehlt in: ${job_file}" return 1 fi } resolve_job_path() { local input="$1" [[ -f "$input" ]] && { echo "$input"; return; } [[ -f "${JOBS_DIR}/${input}" ]] && { echo "${JOBS_DIR}/${input}"; return; } [[ -f "${JOBS_DIR}/${input}.job" ]] && { echo "${JOBS_DIR}/${input}.job"; return; } echo "$input" } is_recording() { local job_id="$1" local pid_file="${PID_DIR}/${job_id}.pid" if [[ -f "$pid_file" ]]; then local pid pid=$(cat "$pid_file") if kill -0 "$pid" 2>/dev/null; then return 0 else rm -f "$pid_file" fi fi return 1 } # ============================================= # Aufnahme # ============================================= record_stream() { local job_file="$1" load_job "$job_file" || return 1 local job_id job_id=$(basename "$job_file" .job) local stream_type="${STREAM_TYPE}" [[ "$stream_type" == "auto" ]] && stream_type=$(detect_stream_type "$STREAM_URL") local ext="${OUTPUT_FORMAT}" [[ -z "$ext" ]] && ext=$(default_extension "$stream_type") local safe_name safe_name=$(echo "$STREAM_NAME" | tr -cs 'A-Za-z0-9_-' '_' | sed 's/_$//') local timestamp timestamp=$(date +'%Y%m%d_%H%M%S') local output_dir="${DOWNLOAD_PATH}/${safe_name}" local output_file="${output_dir}/${safe_name}_${timestamp}.${ext}" local pid_file="${PID_DIR}/${job_id}.pid" local ffmpeg_log="${LOG_DIR}/ffmpeg_${job_id}.log" mkdir -p "$output_dir" if is_recording "$job_id"; then log "WARN" "Aufnahme von '${STREAM_NAME}' läuft bereits (Job: ${job_id})" return 1 fi log "INFO" "Starte Aufnahme: ${STREAM_NAME} [${stream_type}] -> ${output_file}" notify "start" "Aufnahme gestartet" "${STREAM_NAME} [${stream_type}]" local ffmpeg_loglevel="warning" [[ "$VERBOSE" == "true" ]] && ffmpeg_loglevel="info" local -a ffmpeg_args=(-nostdin -y -hide_banner -loglevel "$ffmpeg_loglevel") case "$stream_type" in hls) ffmpeg_args+=(-i "$STREAM_URL" -c copy -bsf:a aac_adtstoasc) ;; *) ffmpeg_args+=(-i "$STREAM_URL" -c copy) ;; esac [[ -n "$MAX_DURATION" ]] && ffmpeg_args+=(-t "$MAX_DURATION") if [[ -n "$EXTRA_FFMPEG_ARGS" ]]; then local -a extra read -ra extra <<< "$EXTRA_FFMPEG_ARGS" ffmpeg_args+=("${extra[@]}") fi ffmpeg_args+=("$output_file") _SR_FFMPEG_PID="" _SR_PID_FILE="$pid_file" _cleanup() { if [[ -n "${_SR_FFMPEG_PID:-}" ]] && kill -0 "$_SR_FFMPEG_PID" 2>/dev/null; then kill -INT "$_SR_FFMPEG_PID" 2>/dev/null || true wait "$_SR_FFMPEG_PID" 2>/dev/null || true fi rm -f "${_SR_PID_FILE:-}" rm -f "${_SR_PID_FILE%.pid}.scheduled" 2>/dev/null || true } trap _cleanup EXIT TERM INT local retry=0 while true; do if [[ "$VERBOSE" == "true" ]]; then ffmpeg "${ffmpeg_args[@]}" > >(tee -a "$ffmpeg_log") 2>&1 & else ffmpeg "${ffmpeg_args[@]}" >> "$ffmpeg_log" 2>&1 & fi _SR_FFMPEG_PID=$! echo "$_SR_FFMPEG_PID" > "$pid_file" local ret=0 wait "$_SR_FFMPEG_PID" || ret=$? _SR_FFMPEG_PID="" if [[ $ret -eq 0 ]]; then log "INFO" "Aufnahme abgeschlossen: ${output_file}" notify "stop" "Aufnahme abgeschlossen" "${STREAM_NAME}: ${output_file}" upload_to_plik "$output_file" "$STREAM_NAME" break fi if [[ $ret -eq 143 || $ret -eq 130 ]]; then log "INFO" "Aufnahme gestoppt: ${STREAM_NAME}" notify "stop" "Aufnahme gestoppt" "${STREAM_NAME} wurde gestoppt" break fi if [[ ! -f "$pid_file" ]]; then log "INFO" "Aufnahme gestoppt: ${STREAM_NAME}" notify "stop" "Aufnahme gestoppt" "${STREAM_NAME} wurde gestoppt" break fi retry=$((retry + 1)) if [[ $MAX_RETRIES -gt 0 && $retry -ge $MAX_RETRIES ]]; then log "ERROR" "Maximale Versuche (${MAX_RETRIES}) für '${STREAM_NAME}' erreicht - Aufnahme abgebrochen" notify "error" "Aufnahme fehlgeschlagen" "${STREAM_NAME}: Max. Versuche (${MAX_RETRIES}) erreicht (Code: ${ret})" "high" break fi local retry_info="" if [[ $MAX_RETRIES -gt 0 ]]; then retry_info=" (Versuch: ${retry}/${MAX_RETRIES})" else retry_info=" (Versuch: ${retry})" fi log "WARN" "Verbindung zu '${STREAM_NAME}' verloren (Code: ${ret})${retry_info}. Reconnect in ${RETRY_DELAY}s..." notify "error" "Stream abgerissen" "${STREAM_NAME}: Verbindung verloren (Code: ${ret}). Reconnect in ${RETRY_DELAY}s..." "default" sleep "$RETRY_DELAY" timestamp=$(date +'%Y%m%d_%H%M%S') output_file="${output_dir}/${safe_name}_${timestamp}.${ext}" ffmpeg_args[-1]="$output_file" done trap - EXIT TERM INT rm -f "$pid_file" rm -f "${pid_file%.pid}.scheduled" 2>/dev/null || true if [[ -f "$output_file" && ! -s "$output_file" ]]; then rm -f "$output_file" log "WARN" "Leere Ausgabedatei entfernt: ${output_file}" fi } record_all() { local found=false for job_file in "${JOBS_DIR}"/*.job; do [[ -f "$job_file" ]] || continue found=true log "INFO" "Starte Job im Hintergrund: $(basename "$job_file")" "$0" ${CONFIG_FILE:+-c "$CONFIG_FILE"} record "$job_file" & done if ! $found; then log "WARN" "Keine .job-Dateien gefunden in: ${JOBS_DIR}" log "INFO" "Erstelle Job-Dateien aus den Vorlagen: cp jobs/templates/example-rtmp.job.dist jobs/mein-stream.job" return 1 fi wait log "INFO" "Alle Aufnahmen beendet" } # ============================================= # Scheduler (wird per systemd-Timer aufgerufen) # ============================================= run_scheduler() { local today_num now_time today_num=$(date '+%u') now_time=$(date '+%H:%M') log "DEBUG" "Scheduler: Tag=${today_num} Zeit=${now_time}" for job_file in "${JOBS_DIR}"/*.job; do [[ -f "$job_file" ]] || continue load_job "$job_file" 2>/dev/null || continue local job_id job_id=$(basename "$job_file" .job) [[ "${SCHEDULE_ENABLED:-false}" != "true" ]] && continue [[ -z "${SCHEDULE_START:-}" ]] && continue local is_today=false _matches_schedule_day "$today_num" "${SCHEDULE_DAYS:-*}" && is_today=true local in_window=false if $is_today; then if [[ -n "${SCHEDULE_STOP:-}" ]]; then _in_time_window "$now_time" "$SCHEDULE_START" "$SCHEDULE_STOP" && in_window=true else local now_min start_min now_min=$(_time_to_minutes "$now_time") start_min=$(_time_to_minutes "$SCHEDULE_START") [[ $now_min -ge $start_min ]] && in_window=true fi fi if $in_window; then if ! is_recording "$job_id"; then log "INFO" "Scheduler: Starte '${STREAM_NAME}' [${job_id}]" notify "start" "Geplante Aufnahme" "${STREAM_NAME} - gestartet durch Scheduler" touch "${PID_DIR}/${job_id}.scheduled" nohup setsid "$0" ${CONFIG_FILE:+-c "$CONFIG_FILE"} record "$job_file" \ >> "${LOG_DIR}/nohup_${job_id}.log" 2>&1 & disown 2>/dev/null || true fi else if is_recording "$job_id" && [[ -f "${PID_DIR}/${job_id}.scheduled" ]]; then if [[ -n "${SCHEDULE_STOP:-}" ]] || ! $is_today; then log "INFO" "Scheduler: Stoppe '${STREAM_NAME}' [${job_id}]" stop_recording "$job_id" rm -f "${PID_DIR}/${job_id}.scheduled" fi fi fi done } # ============================================= # Interaktive Schnellaufnahme # ============================================= interactive_record() { echo "" echo "stream-recorder v${VERSION} - Schnellaufnahme" echo "$(printf '=%.0s' {1..50})" echo "" read -rp "Stream-URL: " url if [[ -z "$url" ]]; then echo "Keine URL angegeben. Abbruch." return 1 fi local detected_type detected_type=$(detect_stream_type "$url") local default_name default_name=$(echo "$url" | sed 's|.*://||; s|/|_|g; s|\?.*||' | tr -cs 'A-Za-z0-9_-' '_' | sed 's/_$//' | cut -c1-40) [[ -z "$default_name" ]] && default_name="Schnellaufnahme" read -rp "Name [${default_name}]: " name name="${name:-$default_name}" read -rp "Typ [${detected_type}]: " custom_type local stream_type="${custom_type:-$detected_type}" read -rp "Dauer (z.B. 30m, 1h30m, 3600 oder leer=unbegrenzt): " duration_input local duration="" if [[ -n "$duration_input" ]]; then duration=$(parse_duration "$duration_input") if [[ -n "$duration" ]]; then local h=$((duration / 3600)) local m=$(( (duration % 3600) / 60 )) local s=$((duration % 60)) echo "Dauer: ${h}h ${m}m ${s}s (${duration} Sekunden)" fi fi echo "" echo "Zusammenfassung:" echo " Name: ${name}" echo " URL: ${url}" echo " Typ: ${stream_type}" echo " Dauer: ${duration:-unbegrenzt}" echo "" read -rp "Aufnahme starten? [J/n] " confirm if [[ "$confirm" =~ ^[Nn]$ ]]; then echo "Abgebrochen." return 1 fi local safe_id safe_id=$(echo "$name" | tr -cs 'A-Za-z0-9_-' '_' | sed 's/_$//') local tmp_job="${PID_DIR}/_quick_${safe_id}.job" cat > "$tmp_job" < "$job_file" </dev/null || continue local job_id job_id=$(basename "$job_file" .job) local status="bereit" if is_recording "$job_id"; then local pid pid=$(cat "${PID_DIR}/${job_id}.pid") status="läuft (PID: ${pid})" fi printf " %-20s %-8s %-40s [%s]\n" "$job_id" "${STREAM_TYPE:-auto}" "$STREAM_URL" "$status" local extra_info="" if [[ "${SCHEDULE_ENABLED:-false}" == "true" && -n "${SCHEDULE_START:-}" ]]; then extra_info="Zeitplan: ${SCHEDULE_DAYS:-*} ${SCHEDULE_START}" if [[ -n "${SCHEDULE_STOP:-}" ]]; then extra_info+=" - ${SCHEDULE_STOP}" elif [[ -n "${MAX_DURATION:-}" ]]; then extra_info+=" (Dauer: ${MAX_DURATION}s)" fi fi if [[ "${PLIK_ENABLED:-false}" == "true" ]]; then extra_info+="${extra_info:+ | }Plik-Upload: an" fi if [[ -n "$extra_info" ]]; then printf " %-20s %s\n" "" "$extra_info" fi done if ! $found; then echo " Keine .job-Dateien gefunden." echo "" echo " Erstelle einen Job aus einer Vorlage:" echo " cp jobs/templates/example-rtmp.job.dist jobs/mein-stream.job" fi echo "" local dist_count=0 for dist_file in "${JOBS_DIR}"/templates/*.job.dist; do [[ -f "$dist_file" ]] || continue dist_count=$((dist_count + 1)) done if [[ $dist_count -gt 0 ]]; then echo "Vorlagen: ${dist_count} Stück (*.job.dist)" fi } show_status() { echo "Laufende Aufnahmen:" echo "$(printf '=%.0s' {1..50})" echo "" local found=false for pid_file in "${PID_DIR}"/*.pid; do [[ -f "$pid_file" ]] || continue local job_id job_id=$(basename "$pid_file" .pid) local pid pid=$(cat "$pid_file") if kill -0 "$pid" 2>/dev/null; then found=true local mode="manuell" [[ -f "${PID_DIR}/${job_id}.scheduled" ]] && mode="geplant" printf " %-20s PID: %-10s [%s]\n" "$job_id" "$pid" "$mode" else rm -f "$pid_file" fi done if ! $found; then echo " Keine aktiven Aufnahmen." fi echo "" if systemctl is-active --quiet stream-recorder.timer 2>/dev/null; then echo "Scheduler: aktiv (systemd-Timer)" else echo "Scheduler: inaktiv" echo " Aktivieren: sudo install/install.sh" fi } stop_recording() { local name="$1" local job_id="${name%.job}" local pid_file="${PID_DIR}/${job_id}.pid" if [[ ! -f "$pid_file" ]]; then log "WARN" "Keine aktive Aufnahme gefunden: ${job_id}" return 1 fi local pid pid=$(cat "$pid_file") rm -f "$pid_file" rm -f "${PID_DIR}/${job_id}.scheduled" 2>/dev/null || true if kill -0 "$pid" 2>/dev/null; then kill -INT "$pid" log "INFO" "Stop-Signal gesendet: ${job_id} (PID: ${pid})" local i=0 while [[ $i -lt 10 ]] && kill -0 "$pid" 2>/dev/null; do sleep 1 i=$((i + 1)) done if kill -0 "$pid" 2>/dev/null; then kill -KILL "$pid" 2>/dev/null || true log "WARN" "Prozess musste erzwungen beendet werden: ${job_id} (PID: ${pid})" fi else log "WARN" "Prozess nicht mehr aktiv: ${job_id} (PID: ${pid})" fi } stop_all() { local found=false for pid_file in "${PID_DIR}"/*.pid; do [[ -f "$pid_file" ]] || continue found=true local job_id job_id=$(basename "$pid_file" .pid) stop_recording "$job_id" done if ! $found; then echo "Keine aktiven Aufnahmen zum Stoppen." fi } watch_logs() { local log_file="${LOG_DIR}/stream-recorder.log" if [[ ! -f "$log_file" ]]; then echo "Noch keine Log-Datei vorhanden: ${log_file}" echo "Starte eine Aufnahme, um Logs zu erzeugen." return 1 fi echo "Log-Datei: ${log_file}" echo "Beenden mit Strg+C" echo "$(printf '=%.0s' {1..50})" tail -f "$log_file" } # ============================================= # Hilfe & Hauptprogramm # ============================================= usage() { cat < [Argumente] Befehle: record Einzelnen Stream aufzeichnen record-all Alle Jobs aufzeichnen create Neuen Job interaktiv erstellen quick Interaktive Schnellaufnahme (URL eingeben) stop Aufnahme stoppen stop-all Alle Aufnahmen stoppen list Jobs und deren Zeitpläne anzeigen status Laufende Aufnahmen und Scheduler-Status scheduler Zeitplanung prüfen und Jobs starten/stoppen logs Log-Ausgabe live verfolgen Optionen: -c, --config Alternative Konfigurationsdatei -V, --verbose Ausführliche Ausgabe -h, --help Diese Hilfe -v, --version Version Zeitplanung: Aufnahmen werden über die Job-Dateien geplant (SCHEDULE_*). Der Scheduler wird automatisch per systemd-Timer ausgeführt. Einrichtung: sudo install/install.sh Beispiele: $(basename "$0") record mein-stream $(basename "$0") quick $(basename "$0") -V record mein-stream $(basename "$0") list $(basename "$0") logs Dokumentation: Siehe README.md EOF } main() { while [[ $# -gt 0 ]]; do case "$1" in -c|--config) CONFIG_FILE="$2"; shift 2 ;; -V|--verbose) VERBOSE=true; shift ;; -h|--help) usage; exit 0 ;; -v|--version) echo "stream-recorder v${VERSION}"; exit 0 ;; *) break ;; esac done load_config check_dependencies local command="${1:-}" shift 2>/dev/null || true case "$command" in record) if [[ -z "${1:-}" ]]; then log "ERROR" "Job-Datei oder Job-Name erforderlich" echo ""; usage; exit 1 fi local job_file job_file=$(resolve_job_path "$1") record_stream "$job_file" ;; record-all) record_all ;; create) create_job ;; quick) interactive_record ;; scheduler) run_scheduler ;; stop) if [[ -z "${1:-}" ]]; then log "ERROR" "Job-Name erforderlich"; exit 1 fi stop_recording "$1" ;; stop-all) stop_all ;; list) list_jobs ;; status) show_status ;; logs) watch_logs ;; "") usage; exit 1 ;; *) log "ERROR" "Unbekannter Befehl: ${command}" echo ""; usage; exit 1 ;; esac } main "$@"