1552 lines
49 KiB
Bash
1552 lines
49 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.7.0
|
|
# Date: 07.09.2026
|
|
# Modifications: Segment-Splitting, erweiterter Status, verbessertes Stoppen
|
|
###############################################
|
|
|
|
set -uo pipefail
|
|
|
|
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
readonly VERSION="2.7.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]+)d ]] && total=$((total + ${BASH_REMATCH[1]} * 86400))
|
|
[[ "$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"
|
|
}
|
|
|
|
format_elapsed() {
|
|
local seconds=$1
|
|
local d=$((seconds / 86400))
|
|
local h=$(( (seconds % 86400) / 3600 ))
|
|
local m=$(( (seconds % 3600) / 60 ))
|
|
local s=$((seconds % 60))
|
|
|
|
if [[ $d -gt 0 ]]; then
|
|
printf "%dd %dh %dm" "$d" "$h" "$m"
|
|
elif [[ $h -gt 0 ]]; then
|
|
printf "%dh %dm %ds" "$h" "$m" "$s"
|
|
else
|
|
printf "%dm %ds" "$m" "$s"
|
|
fi
|
|
}
|
|
|
|
format_size() {
|
|
local bytes=$1
|
|
if [[ $bytes -ge 1073741824 ]]; then
|
|
awk "BEGIN {printf \"%.1f GB\", $bytes/1073741824}"
|
|
elif [[ $bytes -ge 1048576 ]]; then
|
|
awk "BEGIN {printf \"%.1f MB\", $bytes/1048576}"
|
|
elif [[ $bytes -ge 1024 ]]; then
|
|
awk "BEGIN {printf \"%.1f KB\", $bytes/1024}"
|
|
else
|
|
printf "%d B" "$bytes"
|
|
fi
|
|
}
|
|
|
|
write_job_info() {
|
|
local job_id="$1" stream_name="$2" output_dir="$3" output_file="$4"
|
|
local stream_type="$5" segment_mode="$6" job_file="$7"
|
|
local info_file="${PID_DIR}/${job_id}.info"
|
|
|
|
cat > "$info_file" <<INFOEOF
|
|
STREAM_NAME="${stream_name}"
|
|
STREAM_TYPE="${stream_type}"
|
|
OUTPUT_DIR="${output_dir}"
|
|
OUTPUT_FILE="${output_file}"
|
|
START_TIME="$(date '+%Y-%m-%d %H:%M:%S')"
|
|
START_EPOCH=$(date +%s)
|
|
SEGMENT_MODE="${segment_mode}"
|
|
JOB_FILE="${job_file}"
|
|
INFOEOF
|
|
}
|
|
|
|
remove_job_info() {
|
|
local job_id="$1"
|
|
rm -f "${PID_DIR}/${job_id}.info" 2>/dev/null || true
|
|
}
|
|
|
|
# --- 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
|
|
local dist_file="${CONFIG_FILE}.dist"
|
|
if [[ -f "$dist_file" ]]; then
|
|
log "WARN" "Konfigurationsdatei nicht gefunden: ${CONFIG_FILE}"
|
|
log "INFO" "Vorlage kopieren: cp ${dist_file} ${CONFIG_FILE}"
|
|
else
|
|
log "WARN" "Konfigurationsdatei nicht gefunden: ${CONFIG_FILE} - verwende Standardwerte"
|
|
fi
|
|
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"
|
|
SEGMENT_DURATION=""
|
|
|
|
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
|
|
}
|
|
|
|
# =============================================
|
|
# Segment-Upload
|
|
# =============================================
|
|
|
|
_segment_upload_loop() {
|
|
local output_dir="$1" segment_glob="$2" stream_name="$3"
|
|
local uploaded_list="$4" stop_flag="$5"
|
|
|
|
while [[ ! -f "$stop_flag" ]]; do
|
|
sleep 60
|
|
[[ -f "$stop_flag" ]] && break
|
|
|
|
local -a all_segments=()
|
|
for f in "$output_dir"/$segment_glob; do
|
|
[[ -f "$f" ]] && all_segments+=("$f")
|
|
done
|
|
|
|
IFS=$'\n' all_segments=($(sort <<<"${all_segments[*]}")); unset IFS
|
|
|
|
[[ ${#all_segments[@]} -lt 2 ]] && continue
|
|
|
|
local i
|
|
for (( i=0; i < ${#all_segments[@]} - 1; i++ )); do
|
|
local seg="${all_segments[$i]}"
|
|
if ! grep -qxF "$seg" "$uploaded_list" 2>/dev/null; then
|
|
if [[ -s "$seg" ]]; then
|
|
log "INFO" "Segment-Upload: $(basename "$seg")"
|
|
upload_to_plik "$seg" "$stream_name" && echo "$seg" >> "$uploaded_list"
|
|
fi
|
|
fi
|
|
done
|
|
done
|
|
}
|
|
|
|
_upload_remaining_segments() {
|
|
local output_dir="$1" segment_glob="$2" stream_name="$3" uploaded_list="$4"
|
|
|
|
local count=0
|
|
for seg in "$output_dir"/$segment_glob; do
|
|
[[ -f "$seg" ]] || continue
|
|
[[ -s "$seg" ]] || continue
|
|
if ! grep -qxF "$seg" "$uploaded_list" 2>/dev/null; then
|
|
log "INFO" "Segment-Upload (verbleibend): $(basename "$seg")"
|
|
upload_to_plik "$seg" "$stream_name" && echo "$seg" >> "$uploaded_list"
|
|
count=$((count + 1))
|
|
fi
|
|
done
|
|
|
|
[[ $count -gt 0 ]] && log "INFO" "${count} verbleibende(s) Segment(e) hochgeladen"
|
|
rm -f "$uploaded_list" 2>/dev/null || true
|
|
}
|
|
|
|
# =============================================
|
|
# 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 segment_seconds=""
|
|
local segment_mode=false
|
|
if [[ -n "${SEGMENT_DURATION:-}" ]]; then
|
|
segment_seconds=$(parse_duration "$SEGMENT_DURATION")
|
|
if [[ -n "$segment_seconds" && "$segment_seconds" -gt 0 ]]; then
|
|
segment_mode=true
|
|
fi
|
|
fi
|
|
|
|
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 output_pattern=""
|
|
if $segment_mode; then
|
|
output_file="${output_dir}/${safe_name}_${timestamp}_seg%03d.${ext}"
|
|
output_pattern="${safe_name}_${timestamp}_seg*.${ext}"
|
|
fi
|
|
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
|
|
|
|
if $segment_mode; then
|
|
log "INFO" "Starte Aufnahme: ${STREAM_NAME} [${stream_type}] -> ${output_dir} (Segment: alle $(format_elapsed "$segment_seconds"))"
|
|
else
|
|
log "INFO" "Starte Aufnahme: ${STREAM_NAME} [${stream_type}] -> ${output_file}"
|
|
fi
|
|
notify "start" "Aufnahme gestartet" "${STREAM_NAME} [${stream_type}]"
|
|
write_job_info "$job_id" "$STREAM_NAME" "$output_dir" "$output_file" "$stream_type" "$segment_mode" "$job_file"
|
|
|
|
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
|
|
|
|
if $segment_mode; then
|
|
ffmpeg_args+=(-f segment -segment_time "$segment_seconds" -reset_timestamps 1)
|
|
fi
|
|
|
|
[[ -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"
|
|
_SR_JOB_ID="$job_id"
|
|
_SR_SEGMENT_STOP_FLAG=""
|
|
_SR_SEGMENT_UPLOADER_PID=""
|
|
_cleanup() {
|
|
if [[ -n "${_SR_SEGMENT_STOP_FLAG:-}" ]]; then
|
|
touch "$_SR_SEGMENT_STOP_FLAG" 2>/dev/null || true
|
|
fi
|
|
if [[ -n "${_SR_SEGMENT_UPLOADER_PID:-}" ]] && kill -0 "$_SR_SEGMENT_UPLOADER_PID" 2>/dev/null; then
|
|
wait "$_SR_SEGMENT_UPLOADER_PID" 2>/dev/null || true
|
|
fi
|
|
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
|
|
remove_job_info "${_SR_JOB_ID:-}"
|
|
rm -f "${_SR_SEGMENT_STOP_FLAG:-}" 2>/dev/null || true
|
|
}
|
|
trap _cleanup EXIT TERM INT
|
|
|
|
local _sr_uploaded_list=""
|
|
if $segment_mode; then
|
|
_sr_uploaded_list=$(mktemp)
|
|
_SR_SEGMENT_STOP_FLAG=$(mktemp -u)
|
|
fi
|
|
|
|
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"
|
|
|
|
if $segment_mode; then
|
|
rm -f "$_SR_SEGMENT_STOP_FLAG" 2>/dev/null || true
|
|
_segment_upload_loop "$output_dir" "$output_pattern" "$STREAM_NAME" "$_sr_uploaded_list" "$_SR_SEGMENT_STOP_FLAG" &
|
|
_SR_SEGMENT_UPLOADER_PID=$!
|
|
fi
|
|
|
|
local ret=0
|
|
wait "$_SR_FFMPEG_PID" || ret=$?
|
|
_SR_FFMPEG_PID=""
|
|
|
|
if [[ -n "${_SR_SEGMENT_UPLOADER_PID:-}" ]] && kill -0 "$_SR_SEGMENT_UPLOADER_PID" 2>/dev/null; then
|
|
touch "$_SR_SEGMENT_STOP_FLAG" 2>/dev/null || true
|
|
wait "$_SR_SEGMENT_UPLOADER_PID" 2>/dev/null || true
|
|
_SR_SEGMENT_UPLOADER_PID=""
|
|
fi
|
|
|
|
if [[ $ret -eq 0 ]]; then
|
|
if $segment_mode; then
|
|
_upload_remaining_segments "$output_dir" "$output_pattern" "$STREAM_NAME" "$_sr_uploaded_list"
|
|
log "INFO" "Aufnahme abgeschlossen: ${STREAM_NAME} (segmentiert)"
|
|
notify "stop" "Aufnahme abgeschlossen" "${STREAM_NAME} (segmentiert)"
|
|
elif [[ -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 $segment_mode; then
|
|
_upload_remaining_segments "$output_dir" "$output_pattern" "$STREAM_NAME" "$_sr_uploaded_list"
|
|
elif [[ -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 $segment_mode; then
|
|
_upload_remaining_segments "$output_dir" "$output_pattern" "$STREAM_NAME" "$_sr_uploaded_list"
|
|
elif [[ -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"
|
|
|
|
if $segment_mode; then
|
|
_upload_remaining_segments "$output_dir" "$output_pattern" "$STREAM_NAME" "$_sr_uploaded_list"
|
|
fi
|
|
|
|
sleep "$RETRY_DELAY"
|
|
|
|
timestamp=$(date +'%Y%m%d_%H%M%S')
|
|
if $segment_mode; then
|
|
output_file="${output_dir}/${safe_name}_${timestamp}_seg%03d.${ext}"
|
|
output_pattern="${safe_name}_${timestamp}_seg*.${ext}"
|
|
else
|
|
output_file="${output_dir}/${safe_name}_${timestamp}.${ext}"
|
|
fi
|
|
ffmpeg_args[-1]="$output_file"
|
|
done
|
|
|
|
trap - EXIT TERM INT
|
|
rm -f "$pid_file"
|
|
rm -f "${pid_file%.pid}.scheduled" 2>/dev/null || true
|
|
remove_job_info "$job_id"
|
|
rm -f "${_SR_SEGMENT_STOP_FLAG:-}" 2>/dev/null || true
|
|
|
|
if $segment_mode; then
|
|
for seg in "$output_dir"/$output_pattern; do
|
|
[[ -f "$seg" ]] || continue
|
|
if [[ ! -s "$seg" ]]; then
|
|
rm -f "$seg"
|
|
log "WARN" "Leeres Segment entfernt: ${seg}"
|
|
fi
|
|
done
|
|
elif [[ -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}]"
|
|
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:-}" ]]; then
|
|
log "INFO" "Scheduler: Stoppe '${STREAM_NAME}' [${job_id}]"
|
|
stop_recording "$job_id"
|
|
rm -f "${PID_DIR}/${job_id}.scheduled"
|
|
elif ! $is_today && [[ "${SCHEDULE_ONCE:-false}" != "true" ]]; 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
|
|
read -rp "Laufzeit (z.B. 2h, 30m, 2d, 1d12h oder leer=unbegrenzt): " duration_input
|
|
if [[ -n "$duration_input" ]]; then
|
|
duration=$(parse_duration "$duration_input")
|
|
if [[ -n "$duration" && $duration -lt 86400 ]]; then
|
|
local start_min end_min end_h end_m
|
|
start_min=$(_time_to_minutes "$sched_start")
|
|
end_min=$(( start_min + duration / 60 ))
|
|
if [[ $end_min -lt 1440 ]]; then
|
|
end_h=$(( end_min / 60 ))
|
|
end_m=$(( end_min % 60 ))
|
|
sched_stop=$(printf "%02d:%02d" "$end_h" "$end_m")
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [[ "$sched_enabled" != "true" ]]; then
|
|
read -rp "Laufzeit (z.B. 2h, 30m, 2d, 1d12h oder leer=unbegrenzt): " duration_input
|
|
if [[ -n "$duration_input" ]]; then
|
|
duration=$(parse_duration "$duration_input")
|
|
fi
|
|
fi
|
|
|
|
echo ""
|
|
echo "--- Segment-Splitting (optional) ---"
|
|
local segment_duration=""
|
|
read -rp "Aufnahme in Segmente teilen? (z.B. 1h, 2h oder leer=nein): " segment_input
|
|
if [[ -n "$segment_input" ]]; then
|
|
segment_duration=$(parse_duration "$segment_input")
|
|
if [[ -n "$segment_duration" && "$segment_duration" -gt 0 ]]; then
|
|
echo " Segmente alle $(format_elapsed "$segment_duration")"
|
|
else
|
|
segment_duration=""
|
|
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_d=$((duration / 86400)) d_h=$(( (duration % 86400) / 3600 )) d_m=$(( (duration % 3600) / 60 ))
|
|
duration_display=""
|
|
[[ $d_d -gt 0 ]] && duration_display+="${d_d}d "
|
|
duration_display+="${d_h}h ${d_m}m"
|
|
fi
|
|
echo " Dauer: ${duration_display}"
|
|
if [[ -n "$segment_duration" ]]; then
|
|
echo " Segmente: alle $(format_elapsed "$segment_duration")"
|
|
else
|
|
echo " Segmente: deaktiviert"
|
|
fi
|
|
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" <<JOBEOF
|
|
###############################################
|
|
# stream-recorder - Job: ${name}
|
|
###############################################
|
|
|
|
STREAM_NAME="${name}"
|
|
STREAM_URL="${url}"
|
|
STREAM_TYPE="${stream_type}"
|
|
OUTPUT_FORMAT="${ext}"
|
|
MAX_DURATION="${duration}"
|
|
EXTRA_FFMPEG_ARGS=""
|
|
|
|
###############################################
|
|
# Segment-Splitting (optional)
|
|
# Aufnahme in Teile zerlegen, z.B. 1h, 2h, 30m
|
|
###############################################
|
|
|
|
SEGMENT_DURATION="${segment_duration}"
|
|
|
|
###############################################
|
|
# 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}
|
|
DELETE_AFTER_UPLOAD=${delete_after_upload}
|
|
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 [[ -n "${SEGMENT_DURATION:-}" ]]; then
|
|
local seg_secs
|
|
seg_secs=$(parse_duration "$SEGMENT_DURATION")
|
|
if [[ -n "$seg_secs" && "$seg_secs" -gt 0 ]]; then
|
|
extra_info+="${extra_info:+ | }Segmente: alle $(format_elapsed "$seg_secs")"
|
|
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..60})"
|
|
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"
|
|
|
|
local info_file="${PID_DIR}/${job_id}.info"
|
|
if [[ -f "$info_file" ]]; then
|
|
local STREAM_NAME="" STREAM_TYPE="" OUTPUT_DIR="" OUTPUT_FILE=""
|
|
local START_TIME="" START_EPOCH="" SEGMENT_MODE=""
|
|
# shellcheck source=/dev/null
|
|
source "$info_file"
|
|
|
|
printf " %-20s %s\n" "$job_id" "$STREAM_NAME"
|
|
printf " %-20s PID: %s | Modus: %s | Typ: %s\n" "" "$pid" "$mode" "$STREAM_TYPE"
|
|
printf " %-20s Gestartet: %s" "" "$START_TIME"
|
|
|
|
if [[ -n "$START_EPOCH" ]]; then
|
|
local now_epoch elapsed
|
|
now_epoch=$(date +%s)
|
|
elapsed=$((now_epoch - START_EPOCH))
|
|
printf " | Laufzeit: %s" "$(format_elapsed "$elapsed")"
|
|
fi
|
|
echo ""
|
|
|
|
local total_size=0
|
|
if [[ "$SEGMENT_MODE" == "true" && -n "$OUTPUT_DIR" ]]; then
|
|
local seg_count=0
|
|
for seg in "$OUTPUT_DIR"/*_seg*; do
|
|
[[ -f "$seg" ]] || continue
|
|
local s
|
|
s=$(stat -c%s "$seg" 2>/dev/null || echo 0)
|
|
total_size=$((total_size + s))
|
|
seg_count=$((seg_count + 1))
|
|
done
|
|
printf " %-20s Verzeichnis: %s | Segmente: %d | Größe: %s\n" "" "$OUTPUT_DIR" "$seg_count" "$(format_size "$total_size")"
|
|
elif [[ -n "$OUTPUT_FILE" && -f "$OUTPUT_FILE" ]]; then
|
|
total_size=$(stat -c%s "$OUTPUT_FILE" 2>/dev/null || echo 0)
|
|
printf " %-20s Datei: %s | Größe: %s\n" "" "$OUTPUT_FILE" "$(format_size "$total_size")"
|
|
fi
|
|
else
|
|
printf " %-20s PID: %-10s [%s]\n" "$job_id" "$pid" "$mode"
|
|
fi
|
|
echo ""
|
|
else
|
|
rm -f "$pid_file"
|
|
rm -f "${PID_DIR}/${job_id}.info" 2>/dev/null || true
|
|
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 (Teilaufnahme wird hochgeladen)
|
|
stop-all Alle Aufnahmen stoppen
|
|
list Jobs und deren Zeitpläne anzeigen
|
|
status Laufende Aufnahmen mit Details anzeigen
|
|
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
|
|
|
|
Segment-Splitting:
|
|
Aufnahmen können in Segmente (z.B. 1h, 2h) geteilt werden.
|
|
Jedes Segment wird einzeln nach Plik hochgeladen.
|
|
Konfiguration: SEGMENT_DURATION in der Job-Datei.
|
|
|
|
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") status
|
|
$(basename "$0") stop 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 "$@"
|