Files
stream-recorder/stream-recorder.sh
T
2026-09-06 17:25:53 +02:00

1280 lines
39 KiB
Bash

#!/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.5.0
# Date: 06.09.2026
# Modifications: Zeitplanung vereinfacht: Endzeit oder Laufzeit statt separater Max-Dauer
###############################################
set -uo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly VERSION="2.5.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}"
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"
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"
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-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" <<TMPJOB
STREAM_NAME="${name}"
STREAM_URL="${url}"
STREAM_TYPE="${stream_type}"
OUTPUT_FORMAT=""
MAX_DURATION="${duration}"
EXTRA_FFMPEG_ARGS=""
TMPJOB
echo ""
record_stream "$tmp_job"
local result=$?
rm -f "$tmp_job"
return $result
}
# =============================================
# Interaktive Job-Erstellung
# =============================================
create_job() {
echo ""
echo "stream-recorder v${VERSION} - Neuen Job erstellen"
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="Mein-Stream"
read -rp "Stream-Name [${default_name}]: " name
name="${name:-$default_name}"
read -rp "Stream-Typ [${detected_type}]: " custom_type
local stream_type="${custom_type:-$detected_type}"
local default_ext
default_ext=$(default_extension "$stream_type")
read -rp "Ausgabeformat [${default_ext}]: " custom_ext
local ext="${custom_ext:-}"
local duration=""
echo ""
echo "--- Zeitplanung (optional) ---"
read -rp "Zeitplan aktivieren? [j/N] " sched_input
local sched_enabled="false"
local sched_once="false" sched_date="" sched_days="*" sched_start="" sched_stop=""
if [[ "$sched_input" =~ ^[JjYy]$ ]]; then
sched_enabled="true"
read -rp "Einmaliger Termin statt wöchentlich? [j/N] " sched_once_input
if [[ "$sched_once_input" =~ ^[JjYy]$ ]]; then
sched_once="true"
read -rp "Datum (YYYY-MM-DD) [$(date +%F)]: " sched_date_input
sched_date="${sched_date_input:-$(date +%F)}"
else
read -rp "Tage [*] (z.B. Mo-Fr, Sa,So, *): " sched_days_input
sched_days="${sched_days_input:-*}"
fi
read -rp "Startzeit (HH:MM): " sched_start
if [[ -z "$sched_start" ]]; then
echo "Keine Startzeit angegeben - Zeitplan deaktiviert."
sched_enabled="false"
elif [[ "$sched_once" == "true" && -z "$sched_date" ]]; then
echo "Kein Datum angegeben - Zeitplan deaktiviert."
sched_enabled="false"
else
echo ""
echo "Wie soll die Aufnahme begrenzt werden?"
echo " 1) Endzeit angeben (z.B. 18:30)"
echo " 2) Laufzeit angeben (z.B. 1h, 30m)"
echo " 3) Unbegrenzt (läuft bis manuell gestoppt)"
read -rp "Auswahl [1/2/3]: " limit_choice
case "$limit_choice" in
1)
read -rp "Endzeit (HH:MM): " sched_stop
if [[ -n "$sched_stop" ]]; then
local start_min stop_min diff_min
start_min=$(_time_to_minutes "$sched_start")
stop_min=$(_time_to_minutes "$sched_stop")
diff_min=$(( stop_min - start_min ))
[[ $diff_min -le 0 ]] && diff_min=$(( diff_min + 1440 ))
duration=$(( diff_min * 60 ))
echo " -> 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"
if [[ -n "${PLIK_URL:-}" ]]; then
read -rp "Plik-Upload aktivieren? [j/N] " plik_input
[[ "$plik_input" =~ ^[JjYy]$ ]] && plik_enabled="true"
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
echo " Plik: ${plik_enabled}"
echo ""
read -rp "Job erstellen? [J/n] " confirm
if [[ "$confirm" =~ ^[Nn]$ ]]; then
echo "Abgebrochen."
return 1
fi
cat > "$job_file" <<JOBEOF
###############################################
# stream-recorder - Job: ${name}
###############################################
STREAM_NAME="${name}"
STREAM_URL="${url}"
STREAM_TYPE="${stream_type}"
OUTPUT_FORMAT="${ext}"
MAX_DURATION="${duration}"
EXTRA_FFMPEG_ARGS=""
###############################################
# Zeitplanung (optional)
###############################################
SCHEDULE_ENABLED=${sched_enabled}
SCHEDULE_ONCE=${sched_once}
SCHEDULE_DATE="${sched_date}"
SCHEDULE_DAYS="${sched_days}"
SCHEDULE_START="${sched_start}"
SCHEDULE_STOP="${sched_stop}"
###############################################
# Plik-Upload (optional)
###############################################
PLIK_ENABLED=${plik_enabled}
JOBEOF
echo ""
echo "Job erstellt: ${job_file}"
echo ""
echo "Nächste Schritte:"
echo " Bearbeiten: nano ${job_file}"
echo " Starten: $(basename "$0") record ${safe_id}"
echo " Alle zeigen: $(basename "$0") list"
}
# =============================================
# Verwaltung & Status
# =============================================
list_jobs() {
echo "Verfügbare Jobs in: ${JOBS_DIR}"
echo "$(printf '=%.0s' {1..50})"
echo ""
local found=false
for job_file in "${JOBS_DIR}"/*.job; do
[[ -f "$job_file" ]] || continue
found=true
load_job "$job_file" 2>/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 <<EOF
stream-recorder v${VERSION} - Generischer Stream-Recorder
Verwendung: $(basename "$0") [Optionen] <Befehl> [Argumente]
Befehle:
record <job> Einzelnen Stream aufzeichnen
record-all Alle Jobs aufzeichnen
create Neuen Job interaktiv erstellen
quick Interaktive Schnellaufnahme (URL eingeben)
upload <datei> Vorhandene Datei nach Plik hochladen
stop <name> 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 <datei> 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 "$@"