#!/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.6.0 # Date: 06.09.2026 # Modifications: optionale Dateilöschung nach Upload ############################################### set -uo pipefail readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly VERSION="2.6.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" STATE_DIR="/var/lib/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 FOREGROUND=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 } plik_browser_url_from_download_url() { local download_url="$1" local plik_base="${PLIK_URL%/}" local file_path="${download_url#"$plik_base"/file/}" if [[ "$file_path" != "$download_url" ]]; then local upload_id="${file_path%%/*}" if [[ -n "$upload_id" && "$upload_id" != "$file_path" ]]; then echo "${plik_base}/#/?id=${upload_id}" return fi fi echo "$download_url" } upload_to_plik() { local file="$1" local stream_name="$2" [[ "${PLIK_ENABLED:-false}" != "true" ]] && return [[ -z "${PLIK_URL:-}" ]] && return if [[ ! -f "$file" ]]; then log "WARN" "Plik-Upload übersprungen: Datei nicht gefunden: ${file}" return 1 fi if [[ ! -s "$file" ]]; then log "WARN" "Plik-Upload übersprungen: Datei ist leer: ${file}" return 1 fi 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=(-sS --max-time 7200) 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 curl_args+=(-F "file=@${file};filename=${filename}") [[ -n "$ttl_seconds" ]] && curl_args+=(-F "ttl=${ttl_seconds}") log "INFO" "Plik-Upload: ${filename} -> ${plik_base}" local upload_tmp upload_response http_code curl_status=0 upload_tmp=$(mktemp) http_code=$(curl "${curl_args[@]}" \ -w "%{http_code}" \ -o "$upload_tmp" \ "${plik_base}" 2>/dev/null) || curl_status=$? upload_response=$(cat "$upload_tmp" 2>/dev/null || true) rm -f "$upload_tmp" if [[ $curl_status -ne 0 || ! "$http_code" =~ ^2[0-9][0-9]$ ]]; then log "ERROR" "Plik-Upload fehlgeschlagen: Datei konnte nicht hochgeladen werden (HTTP ${http_code:-000}, curl ${curl_status})" [[ -n "$upload_response" ]] && log "DEBUG" "Plik-Antwort: ${upload_response}" return 1 fi local download_url download_url=$(printf '%s\n' "$upload_response" | grep -Eo 'https?://[^[:space:]]+' | tail -1) if [[ -z "$download_url" ]]; then log "ERROR" "Plik-Upload fehlgeschlagen: Keine Download-URL in der Antwort" log "DEBUG" "Plik-Antwort: ${upload_response}" return 1 fi local browser_url browser_url=$(plik_browser_url_from_download_url "$download_url") log "INFO" "Plik-Upload abgeschlossen: ${browser_url}" if [[ "$browser_url" != "$download_url" ]]; then log "INFO" "Plik-Datei-URL: ${download_url}" fi notify "upload" "Plik-Upload" "${stream_name}: ${browser_url}" if [[ "${DELETE_AFTER_UPLOAD:-false}" == "true" ]]; then rm -f "$file" log "INFO" "Lokale Datei gelöscht: ${file}" fi echo "$browser_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" "$STATE_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_ONCE="false" SCHEDULE_DATE="" SCHEDULE_DAYS="*" SCHEDULE_START="" SCHEDULE_STOP="" PLIK_ENABLED="false" DELETE_AFTER_UPLOAD="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 if [[ "${SCHEDULE_ONCE:-false}" == "true" ]]; then if [[ -z "${SCHEDULE_DATE:-}" ]]; then log "ERROR" "SCHEDULE_DATE fehlt in einmaligem Zeitplan: ${job_file}" return 1 fi if [[ ! "${SCHEDULE_DATE}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then log "ERROR" "Ungültiges SCHEDULE_DATE in ${job_file}: ${SCHEDULE_DATE} (erwartet: YYYY-MM-DD)" return 1 fi fi if [[ -n "$MAX_DURATION" && "$MAX_DURATION" != *:* ]]; then local normalized_duration normalized_duration=$(parse_duration "$MAX_DURATION") if [[ -z "$normalized_duration" ]]; then log "ERROR" "Ungültige MAX_DURATION in ${job_file}: ${MAX_DURATION}" return 1 fi MAX_DURATION="$normalized_duration" 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 } schedule_state_file() { local job_id="$1" echo "${STATE_DIR}/${job_id}.last-scheduled" } schedule_occurrence_key() { local schedule_date if [[ "${SCHEDULE_ONCE:-false}" == "true" ]]; then schedule_date="${SCHEDULE_DATE:-$(date '+%F')}" else schedule_date=$(date '+%F') fi echo "${SCHEDULE_ONCE:-false}|${schedule_date}|${SCHEDULE_DAYS:-*}|${SCHEDULE_START:-}|${SCHEDULE_STOP:-}|${MAX_DURATION:-}" } schedule_already_started() { local job_id="$1" local occurrence_key="$2" local state_file state_file=$(schedule_state_file "$job_id") [[ -f "$state_file" ]] || return 1 [[ "$(cat "$state_file" 2>/dev/null || true)" == "$occurrence_key" ]] } mark_schedule_started() { local job_id="$1" local occurrence_key="$2" local state_file state_file=$(schedule_state_file "$job_id") mkdir -p "$(dirname "$state_file")" 2>/dev/null || true if ! printf '%s\n' "$occurrence_key" > "$state_file"; then log "ERROR" "Scheduler-Status konnte nicht geschrieben werden: ${state_file}" return 1 fi } # ============================================= # 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 if [[ -s "$output_file" ]]; then log "INFO" "Aufnahme abgeschlossen: ${output_file}" notify "stop" "Aufnahme abgeschlossen" "${STREAM_NAME}: ${output_file}" upload_to_plik "$output_file" "$STREAM_NAME" else [[ -f "$output_file" ]] && rm -f "$output_file" log "ERROR" "Aufnahme fehlgeschlagen: Ausgabedatei fehlt oder ist leer (${output_file})" notify "error" "Aufnahme fehlgeschlagen" "${STREAM_NAME}: Ausgabedatei fehlt oder ist leer" "high" fi break fi if [[ $ret -eq 143 || $ret -eq 130 ]]; then log "INFO" "Aufnahme gestoppt: ${STREAM_NAME}" notify "stop" "Aufnahme gestoppt" "${STREAM_NAME} wurde gestoppt" if [[ -s "$output_file" ]]; then upload_to_plik "$output_file" "$STREAM_NAME" fi break fi if [[ ! -f "$pid_file" ]]; then log "INFO" "Aufnahme gestoppt: ${STREAM_NAME}" notify "stop" "Aufnahme gestoppt" "${STREAM_NAME} wurde gestoppt" if [[ -s "$output_file" ]]; then upload_to_plik "$output_file" "$STREAM_NAME" fi 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-run "$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" } start_detached_recording() { local job_file="$1" load_job "$job_file" || return 1 local job_id job_id=$(basename "$job_file" .job) if is_recording "$job_id"; then log "WARN" "Aufnahme von '${STREAM_NAME}' läuft bereits (Job: ${job_id})" return 1 fi local nohup_log="${LOG_DIR}/nohup_${job_id}.log" log "INFO" "Starte Aufnahme im Hintergrund: ${STREAM_NAME} [${job_id}]" nohup setsid "$0" ${CONFIG_FILE:+-c "$CONFIG_FILE"} record-run "$job_file" \ >> "$nohup_log" 2>&1 < /dev/null & local detached_pid=$! disown 2>/dev/null || true echo "Aufnahme im Hintergrund gestartet: ${STREAM_NAME} [${job_id}]" echo "Starter-PID: ${detached_pid}" echo "Logs: ${LOG_DIR}/stream-recorder.log" } # ============================================= # Scheduler (wird per systemd-Timer aufgerufen) # ============================================= run_scheduler() { local today_date today_num now_time today_date=$(date '+%F') today_num=$(date '+%u') now_time=$(date '+%H:%M') log "DEBUG" "Scheduler: Datum=${today_date} 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 if [[ "${SCHEDULE_ONCE:-false}" == "true" ]]; then [[ "${SCHEDULE_DATE:-}" == "$today_date" ]] && is_today=true else _matches_schedule_day "$today_num" "${SCHEDULE_DAYS:-*}" && is_today=true fi 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 local occurrence_key occurrence_key=$(schedule_occurrence_key) if schedule_already_started "$job_id" "$occurrence_key"; then log "DEBUG" "Scheduler: '${STREAM_NAME}' [${job_id}] wurde für dieses Zeitfenster bereits gestartet" continue fi if ! is_recording "$job_id"; then log "INFO" "Scheduler: Starte '${STREAM_NAME}' [${job_id}]" notify "start" "Geplante Aufnahme" "${STREAM_NAME} - gestartet durch Scheduler" mark_schedule_started "$job_id" "$occurrence_key" || continue printf '%s\n' "$occurrence_key" > "${PID_DIR}/${job_id}.scheduled" nohup setsid "$0" ${CONFIG_FILE:+-c "$CONFIG_FILE"} record-run "$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" < Laufzeit automatisch berechnet: ${diff_min} Minuten" fi ;; 2) read -rp "Laufzeit (z.B. 1h, 30m, 1h30m, 3600): " duration_input if [[ -n "$duration_input" ]]; then duration=$(parse_duration "$duration_input") if [[ -n "$duration" ]]; then local start_min end_min end_h end_m start_min=$(_time_to_minutes "$sched_start") end_min=$(( start_min + duration / 60 )) [[ $end_min -ge 1440 ]] && end_min=$(( end_min - 1440 )) end_h=$(( end_min / 60 )) end_m=$(( end_min % 60 )) sched_stop=$(printf "%02d:%02d" "$end_h" "$end_m") echo " -> Endzeit automatisch berechnet: ${sched_stop}" fi fi ;; *) ;; esac fi fi if [[ "$sched_enabled" != "true" ]]; then read -rp "Laufzeit (z.B. 1h, 30m, 3600 oder leer=unbegrenzt): " duration_input if [[ -n "$duration_input" ]]; then duration=$(parse_duration "$duration_input") fi fi echo "" echo "--- Plik-Upload (optional) ---" local plik_enabled="false" local delete_after_upload="false" if [[ -n "${PLIK_URL:-}" ]]; then read -rp "Plik-Upload aktivieren? [j/N] " plik_input if [[ "$plik_input" =~ ^[JjYy]$ ]]; then plik_enabled="true" read -rp "Lokale Datei nach Upload löschen? [j/N] " delete_input [[ "$delete_input" =~ ^[JjYy]$ ]] && delete_after_upload="true" fi else echo " (Übersprungen - PLIK_URL nicht konfiguriert)" fi local safe_id safe_id=$(echo "$name" | tr -cs 'A-Za-z0-9_-' '_' | sed 's/_$//' | tr '[:upper:]' '[:lower:]') local job_file="${JOBS_DIR}/${safe_id}.job" if [[ -f "$job_file" ]]; then echo "" read -rp "Job '${safe_id}' existiert bereits. Überschreiben? [j/N] " overwrite if [[ ! "$overwrite" =~ ^[JjYy]$ ]]; then echo "Abgebrochen." return 1 fi fi echo "" echo "Zusammenfassung:" echo " Datei: ${job_file}" echo " Name: ${name}" echo " URL: ${url}" echo " Typ: ${stream_type}" echo " Format: ${ext:-auto}" local duration_display="unbegrenzt" if [[ -n "$duration" ]]; then local d_h=$((duration / 3600)) d_m=$(( (duration % 3600) / 60 )) duration_display="${d_h}h ${d_m}m (${duration}s)" fi echo " Dauer: ${duration_display}" if [[ "$sched_enabled" == "true" ]]; then if [[ "$sched_once" == "true" ]]; then echo " Zeitplan: einmalig ${sched_date} ${sched_start}${sched_stop:+ - ${sched_stop}}" else echo " Zeitplan: wöchentlich ${sched_days} ${sched_start}${sched_stop:+ - ${sched_stop}}" fi else echo " Zeitplan: deaktiviert" fi if [[ "$plik_enabled" == "true" && "$delete_after_upload" == "true" ]]; then echo " Plik: ${plik_enabled} (nach Upload löschen)" else echo " Plik: ${plik_enabled}" fi echo "" read -rp "Job erstellen? [J/n] " confirm if [[ "$confirm" =~ ^[Nn]$ ]]; then echo "Abgebrochen." return 1 fi cat > "$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 if [[ "${SCHEDULE_ONCE:-false}" == "true" ]]; then extra_info="Zeitplan: einmalig ${SCHEDULE_DATE:-?} ${SCHEDULE_START}" else extra_info="Zeitplan: wöchentlich ${SCHEDULE_DAYS:-*} ${SCHEDULE_START}" fi 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) upload Vorhandene Datei nach Plik hochladen 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 --foreground Aufnahme im Vordergrund ausführen -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 ;; --foreground) FOREGROUND=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") if [[ "$FOREGROUND" == "true" || "$VERBOSE" == "true" ]]; then record_stream "$job_file" else start_detached_recording "$job_file" fi ;; record-run) 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 ;; upload) if [[ -z "${1:-}" ]]; then log "ERROR" "Dateipfad erforderlich"; exit 1 fi PLIK_ENABLED=true upload_to_plik "$1" "${2:-$(basename "$1")}" ;; 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 "$@"