#!/bin/bash # Script to re-encode video files for optimal Plex/web streaming # Uses H.265/HEVC with web-optimized quality (CRF 25) # Features: NVENC GPU acceleration, faststart for streaming, in-place replacement # Adds [E] flag to filename after successful encoding # Enable strict error handling set -o pipefail # Catch failures in piped commands # Use environment variable if set (for Docker), otherwise use default path # Target public directory only (curated content for streaming) ROOT_DIR="${SOURCE_LIBRARY:-/media/bunker-admin/Internal/plex/xxx/media/public}" LOG_FILE="$ROOT_DIR/reencode_log_$(date +%Y%m%d_%H%M%S).txt" # Minimum file size ratio (encoded/original) - below this suggests encoding failure MIN_SIZE_RATIO=0.05 # Quality settings CRF_VALUE=25 AUDIO_BITRATE="128k" cd "$ROOT_DIR" || exit 1 # Check dependencies if ! command -v ffmpeg &> /dev/null; then echo "Error: ffmpeg is not installed." exit 1 fi if ! command -v ffprobe &> /dev/null; then echo "Error: ffprobe is not installed." exit 1 fi # Detect encoder detect_encoder() { # Check if CPU mode is forced if [[ "$FORCE_CPU" == true ]]; then echo "Using CPU encoder (libx265) [--cpu flag]" ENCODER="libx265" ENCODER_OPTS="-preset medium -crf $CRF_VALUE -x265-params log-level=error" return fi if ffmpeg -hide_banner -encoders 2>/dev/null | grep -q hevc_nvenc; then # Actually test NVENC with a real encode (not just check if listed) local test_output="/tmp/nvenc_detect_test_$$.mp4" local test_result test_result=$(ffmpeg -y -f lavfi -i "color=black:s=256x256:d=0.1" \ -c:v hevc_nvenc -preset p4 -gpu 0 \ "$test_output" 2>&1) local test_exit=$? rm -f "$test_output" if [[ $test_exit -ne 0 ]]; then # Parse specific CUDA errors for helpful messages echo "" if echo "$test_result" | grep -q "CUDA_ERROR_UNKNOWN"; then echo "WARNING: NVENC unavailable - GPU encoder is in a bad state" echo " Error: CUDA context creation failed" echo " Fix: Reboot, or log out/in to release GPU resources" elif echo "$test_result" | grep -q "No capable devices"; then echo "WARNING: NVENC unavailable - no capable GPU devices found" else echo "WARNING: NVENC test encode failed" local err_line=$(echo "$test_result" | grep -i -E 'error|failed' | head -1) if [[ -n "$err_line" ]]; then echo " $err_line" fi fi echo "" echo "Falling back to CPU encoder (libx265)" ENCODER="libx265" ENCODER_OPTS="-preset medium -crf $CRF_VALUE -x265-params log-level=error" return fi # NVENC works - check if currently busy (use session count, not utilization %) local enc_sessions=$(nvidia-smi --query-gpu=encoder.stats.sessionCount --format=csv,noheader 2>/dev/null | head -1) if [[ -n "$enc_sessions" ]] && [[ "$enc_sessions" -gt 0 ]]; then echo "" echo "WARNING: GPU encoder has $enc_sessions active session(s)" echo "" echo "Options:" echo " 1) Press Enter to continue with GPU (may queue or fail)" echo " 2) Type 'cpu' to use CPU encoding (slower but reliable)" echo " 3) Press Ctrl+C to cancel" echo "" read -p "Choice: " enc_choice if [[ "$enc_choice" == "cpu" ]]; then ENCODER="libx265" ENCODER_OPTS="-preset medium -crf $CRF_VALUE -x265-params log-level=error" return fi fi echo "Using NVENC GPU encoder (hevc_nvenc)" ENCODER="hevc_nvenc" # -cq is NVENC's quality mode (similar to CRF), p4 is balanced preset # -gpu 0 explicitly selects first GPU # -tag:v hvc1 ensures Apple device compatibility ENCODER_OPTS="-preset p4 -cq $CRF_VALUE -rc vbr -gpu 0 -tag:v hvc1" else echo "NVENC not available, using CPU encoder (libx265)" ENCODER="libx265" ENCODER_OPTS="-preset medium -crf $CRF_VALUE -x265-params log-level=error" fi } # Check if file is already H.265/HEVC is_hevc() { local file="$1" local codec=$(ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 "$file" 2>/dev/null) [[ "$codec" == "hevc" ]] || [[ "$codec" == "h265" ]] } # Check if filename already has [E] encoded flag has_encoded_flag() { local file="$1" local filename=$(basename "$file") [[ "$filename" =~ \[E\] ]] } # Add [E] flag to filename (before [H/V] orientation tag, compatible with organize_by_studio.sh) add_encoded_flag() { local file="$1" local dir=$(dirname "$file") local filename=$(basename "$file") local new_filename # Insert [E] before [H] or [V] orientation tag if present if [[ "$filename" =~ ^(.+)\ \[([HV])\]\.mp4$ ]]; then local base="${BASH_REMATCH[1]}" local orientation="${BASH_REMATCH[2]}" new_filename="${base} [E] [${orientation}].mp4" else # Fallback: add before .mp4 if no orientation tag found local name="${filename%.mp4}" new_filename="${name} [E].mp4" fi local new_path="${dir}/${new_filename}" mv "$file" "$new_path" echo "$new_path" } # Extract duration in seconds from filename tags [H:MM] or [Xs] get_duration_from_filename() { local file="$1" local filename=$(basename "$file") local seconds=0 # Try [H:MM] format (e.g., [1:23] = 1 hour 23 minutes) if [[ "$filename" =~ \[([0-9]+):([0-9]+)\] ]]; then # Use 10# prefix to force base-10 interpretation (avoids octal errors with 08, 09) local hours=$((10#${BASH_REMATCH[1]})) local minutes=$((10#${BASH_REMATCH[2]})) seconds=$((hours * 3600 + minutes * 60)) # Try [Xs] format for short clips (e.g., [45s]) elif [[ "$filename" =~ \[([0-9]+)s\] ]]; then seconds=$((10#${BASH_REMATCH[1]})) fi echo "$seconds" } # Format seconds as human-readable time format_time() { local seconds=$1 local hours=$((seconds / 3600)) local minutes=$(((seconds % 3600) / 60)) local secs=$((seconds % 60)) if [[ $hours -gt 0 ]]; then printf "%dh %dm %ds" "$hours" "$minutes" "$secs" elif [[ $minutes -gt 0 ]]; then printf "%dm %ds" "$minutes" "$secs" else printf "%ds" "$secs" fi } # Format video duration for display format_video_duration() { local seconds=$1 local hours=$((seconds / 3600)) local minutes=$(((seconds % 3600) / 60)) if [[ $hours -gt 0 ]]; then printf "%dh %dm" "$hours" "$minutes" else printf "%dm" "$minutes" fi } # Check if file has faststart (moov atom at beginning) has_faststart() { local file="$1" # Check if moov atom appears before mdat atom local atoms=$(ffprobe -v error -show_format -show_entries format_tags "$file" 2>&1 | head -20) # Simple heuristic: if file streams well, it likely has faststart # More accurate: use atomicparsley or similar, but ffprobe works for our needs return 0 # We'll re-encode anyway to ensure proper settings } # Get video info get_video_info() { local file="$1" local codec=$(ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 "$file" 2>/dev/null) local width=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of default=noprint_wrappers=1:nokey=1 "$file" 2>/dev/null) local height=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of default=noprint_wrappers=1:nokey=1 "$file" 2>/dev/null) local bitrate=$(ffprobe -v error -show_entries format=bit_rate -of default=noprint_wrappers=1:nokey=1 "$file" 2>/dev/null) if [[ -n "$bitrate" ]] && [[ "$bitrate" != "N/A" ]]; then bitrate=$((bitrate / 1000))k else bitrate="unknown" fi echo "${codec}:${width}x${height}:${bitrate}" } # Format file size for display format_size() { local bytes=$1 if [[ $bytes -ge 1073741824 ]]; then echo "$(echo "scale=2; $bytes / 1073741824" | bc)G" elif [[ $bytes -ge 1048576 ]]; then echo "$(echo "scale=2; $bytes / 1048576" | bc)M" elif [[ $bytes -ge 1024 ]]; then echo "$(echo "scale=2; $bytes / 1024" | bc)K" else echo "${bytes}B" fi } # Log message to file and stdout log() { echo "$1" | tee -a "$LOG_FILE" } # Debug log function debug_log() { if [[ "$DEBUG" == true ]]; then log " [DEBUG] $1" fi } # Check GPU status check_gpu_status() { if ! command -v nvidia-smi &> /dev/null; then debug_log "nvidia-smi not found" return 1 fi local gpu_info=$(nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu,encoder.stats.sessionCount --format=csv,noheader 2>/dev/null) if [[ -n "$gpu_info" ]]; then debug_log "GPU Status: $gpu_info" fi # Check encoder sessions local enc_sessions=$(nvidia-smi --query-gpu=encoder.stats.sessionCount --format=csv,noheader 2>/dev/null | head -1) if [[ -n "$enc_sessions" ]] && [[ "$enc_sessions" -gt 0 ]]; then debug_log "Active encoder sessions: $enc_sessions" fi return 0 } # Test NVENC with a quick encode test_nvenc() { debug_log "Testing NVENC availability..." # Create a tiny test pattern and try to encode it local test_output="/tmp/nvenc_test_$$.mp4" local test_result test_result=$(ffmpeg -y -f lavfi -i "color=black:s=256x256:d=0.1" \ -c:v hevc_nvenc -preset p4 -gpu 0 \ "$test_output" 2>&1) local test_exit=$? rm -f "$test_output" if [[ $test_exit -eq 0 ]]; then debug_log "NVENC test: SUCCESS" return 0 else debug_log "NVENC test: FAILED (exit $test_exit)" debug_log "NVENC error: $(echo "$test_result" | grep -i -E 'error|failed|cannot' | head -3)" return 1 fi } # Main encoding function encode_file() { local input="$1" local temp_output="${input}.temp.mp4" # Get original file size local original_size=$(stat -c%s "$input" 2>/dev/null) if [[ -z "$original_size" ]] || [[ "$original_size" -eq 0 ]]; then log " ERROR: Cannot read input file" return 1 fi local original_size_fmt=$(format_size $original_size) local video_info=$(get_video_info "$input") log " Original: $video_info ($original_size_fmt)" # Check GPU status if using NVENC if [[ "$ENCODER" == "hevc_nvenc" ]] && [[ "$DEBUG" == true ]]; then check_gpu_status fi local encode_output="" local exit_code=1 local used_encoder="$ENCODER" local attempt=0 # GPU encoding with retries if [[ "$ENCODER" == "hevc_nvenc" ]]; then for ((attempt=1; attempt<=GPU_RETRIES; attempt++)); do debug_log "GPU encode attempt $attempt/$GPU_RETRIES" # Clean up any previous temp file rm -f "$temp_output" # Build ffmpeg command local ffmpeg_cmd="ffmpeg -nostdin -hide_banner -i \"$input\" \ -c:v hevc_nvenc $ENCODER_OPTS \ -c:a aac -b:a $AUDIO_BITRATE \ -movflags +faststart \ -y \"$temp_output\"" debug_log "Command: ffmpeg -c:v hevc_nvenc $ENCODER_OPTS -c:a aac -b:a $AUDIO_BITRATE" # Run encoding if [[ "$DEBUG" == true ]]; then encode_output=$(eval "$ffmpeg_cmd" 2>&1) exit_code=$? # Show last few lines of output in debug mode debug_log "Exit code: $exit_code" echo "$encode_output" | tail -5 | while read -r line; do debug_log "ffmpeg: $line" done else encode_output=$(eval "$ffmpeg_cmd" 2>&1) exit_code=$? fi # Check for success if [[ $exit_code -eq 0 ]] && [[ -f "$temp_output" ]]; then local temp_size=$(stat -c%s "$temp_output" 2>/dev/null) if [[ -n "$temp_size" ]] && [[ "$temp_size" -gt 0 ]]; then debug_log "GPU encode SUCCESS on attempt $attempt" used_encoder="hevc_nvenc" break fi fi # Parse error for debugging local nvenc_error=$(echo "$encode_output" | grep -i -E 'error|failed|cannot|no capable|cuda' | head -1) if [[ -n "$nvenc_error" ]]; then debug_log "NVENC error: $nvenc_error" fi # Wait before retry (increasing delay) if [[ $attempt -lt $GPU_RETRIES ]]; then local wait_time=$((attempt * 2)) debug_log "Waiting ${wait_time}s before retry..." sleep $wait_time fi done # If all GPU attempts failed, fall back to CPU if [[ $exit_code -ne 0 ]] || [[ ! -f "$temp_output" ]]; then # Always show the error (not just in debug mode) local error_summary=$(echo "$encode_output" | grep -i -E 'error|failed|cannot|cuda' | head -1) if [[ -n "$error_summary" ]]; then log " NVENC failed: $error_summary" else log " NVENC failed after $GPU_RETRIES attempts" fi log " Falling back to CPU encoder..." rm -f "$temp_output" used_encoder="libx265" local cpu_cmd="ffmpeg -nostdin -hide_banner -i \"$input\" \ -c:v libx265 -preset medium -crf $CRF_VALUE -x265-params log-level=error \ -c:a aac -b:a $AUDIO_BITRATE \ -movflags +faststart \ -y \"$temp_output\"" debug_log "Falling back to CPU: libx265 -preset medium -crf $CRF_VALUE" if [[ "$DEBUG" == true ]]; then encode_output=$(eval "$cpu_cmd" 2>&1) exit_code=$? debug_log "CPU encode exit code: $exit_code" else encode_output=$(eval "$cpu_cmd" 2>&1) exit_code=$? fi fi else # CPU encoding (no retries needed) debug_log "Using CPU encoder directly" local cpu_cmd="ffmpeg -nostdin -hide_banner -i \"$input\" \ -c:v $ENCODER $ENCODER_OPTS \ -c:a aac -b:a $AUDIO_BITRATE \ -movflags +faststart \ -y \"$temp_output\"" if [[ "$DEBUG" == true ]]; then encode_output=$(eval "$cpu_cmd" 2>&1) exit_code=$? debug_log "CPU encode exit code: $exit_code" else encode_output=$(eval "$cpu_cmd" 2>&1) exit_code=$? fi used_encoder="$ENCODER" fi # Verify encoding success if [[ $exit_code -ne 0 ]] || [[ ! -f "$temp_output" ]]; then log " ERROR: Encoding failed (exit code: $exit_code)" if [[ "$DEBUG" == true ]]; then echo "$encode_output" | grep -i -E 'error|failed|invalid' | head -3 | while read -r line; do debug_log "Error detail: $line" done fi rm -f "$temp_output" return 1 fi # Check output file size local new_size=$(stat -c%s "$temp_output" 2>/dev/null) if [[ -z "$new_size" ]] || [[ "$new_size" -eq 0 ]]; then log " ERROR: Output file is empty or missing" rm -f "$temp_output" return 1 fi local new_size_fmt=$(format_size $new_size) # Verify file isn't suspiciously small (encoding failure indicator) local size_ratio=$(echo "scale=4; $new_size / $original_size" | bc) if (( $(echo "$size_ratio < $MIN_SIZE_RATIO" | bc -l) )); then log " ERROR: Output file too small ($(format_size $new_size)) - likely corrupted" rm -f "$temp_output" return 1 fi # Verify output is playable local verify_output=$(ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 "$temp_output" 2>&1) if [[ -z "$verify_output" ]] || [[ "$verify_output" == *"Invalid"* ]]; then log " ERROR: Output file verification failed" rm -f "$temp_output" return 1 fi # Calculate space savings local saved=$((original_size - new_size)) local saved_fmt=$(format_size $saved) local percent_saved=$(echo "scale=1; 100 - ($new_size * 100 / $original_size)" | bc) # Replace original with encoded file mv "$temp_output" "$input" if [[ $saved -gt 0 ]]; then log " Encoded: hevc/$used_encoder ($new_size_fmt) - saved $saved_fmt ($percent_saved%)" else log " Encoded: hevc/$used_encoder ($new_size_fmt) - increased by $(format_size $((-saved)))" fi return 0 } # Show usage show_usage() { echo "" echo "Usage: $0 [OPTIONS]" echo "" echo "Re-encodes video files to H.265/HEVC for streaming optimization." echo "Adds [E] flag to filename after successful encoding." echo "" echo "Options:" echo " --dry-run Show what would be encoded without making changes" echo " --cpu Force CPU encoding (skip NVENC, slower but reliable)" echo " --debug Show verbose ffmpeg output and GPU diagnostics" echo " --skip-hevc Skip files already encoded in H.265/HEVC (default)" echo " --force-all Re-encode all files, even if already HEVC" echo " --dir PATH Process only files in specified directory" echo " --single FILE Process only a single file" echo " --help Show this help message" echo "" echo "Files with [E] in filename are always skipped." echo "" } # Parse arguments DRY_RUN=false SKIP_HEVC=true # Default: skip already-encoded HEVC files FORCE_CPU=false DEBUG=false GPU_RETRIES=3 # Number of times to retry GPU encoding before falling back TARGET_DIR="" SINGLE_FILE="" while [[ $# -gt 0 ]]; do case $1 in --dry-run) DRY_RUN=true shift ;; --skip-hevc) SKIP_HEVC=true shift ;; --force-all) SKIP_HEVC=false shift ;; --cpu) FORCE_CPU=true shift ;; --debug) DEBUG=true shift ;; --dir) TARGET_DIR="$2" shift 2 ;; --single) SINGLE_FILE="$2" shift 2 ;; --help) show_usage exit 0 ;; *) echo "Unknown option: $1" show_usage exit 1 ;; esac done # Detect encoder detect_encoder # Run NVENC test if debug mode and using GPU if [[ "$DEBUG" == true ]] && [[ "$ENCODER" == "hevc_nvenc" ]]; then echo "" echo "[DEBUG] Running NVENC diagnostics..." check_gpu_status if ! test_nvenc; then echo "[DEBUG] WARNING: NVENC test failed - encoding may fall back to CPU" fi fi echo "" echo "=========================================" echo " Streaming-Optimized Re-encoder" echo "=========================================" echo " Encoder: $ENCODER (H.265/HEVC)" echo " Quality: CRF $CRF_VALUE (web-optimized)" echo " Audio: AAC @ $AUDIO_BITRATE" echo " Mode: In-place replacement + [E] flag" if [[ "$SKIP_HEVC" == true ]]; then echo " Skipping: Files with [E] flag or HEVC codec" fi if [[ "$DRY_RUN" == true ]]; then echo " DRY RUN: No files will be modified" fi if [[ "$DEBUG" == true ]]; then echo " DEBUG MODE: Verbose output enabled" echo " GPU Retries: $GPU_RETRIES attempts before CPU fallback" fi echo " Log: $LOG_FILE" echo "=========================================" echo "" # Initialize counters total_files=0 processed_files=0 skipped_files=0 flagged_files=0 failed_files=0 total_saved=0 total_video_duration=0 encoded_video_duration=0 start_time=$(date +%s) # Build file list if [[ -n "$SINGLE_FILE" ]]; then files=("$SINGLE_FILE") elif [[ -n "$TARGET_DIR" ]]; then mapfile -t files < <(find "$TARGET_DIR" -type f -name "*.mp4" ! -name "*.temp.mp4" | sort) else # Find all MP4 files, excluding certain directories and temp files mapfile -t files < <(find . -type f -name "*.mp4" \ ! -name "*.temp.mp4" \ ! -path "*/scripts/*" \ ! -path "*/.temp/*" \ | sort) fi total_files=${#files[@]} log "Found $total_files MP4 files to process" # Pre-scan to calculate total duration needing encoding echo "Scanning files to estimate encoding time..." files_to_encode=0 for file in "${files[@]}"; do # Skip files with [E] flag if has_encoded_flag "$file"; then continue fi # Skip HEVC files (they just get flagged, no encoding) if [[ "$SKIP_HEVC" == true ]] && is_hevc "$file"; then continue fi # This file needs encoding - add its duration duration=$(get_duration_from_filename "$file") total_video_duration=$((total_video_duration + duration)) ((files_to_encode++)) done if [[ $files_to_encode -gt 0 ]]; then log "Files to encode: $files_to_encode ($(format_video_duration $total_video_duration) of video)" else log "No files need encoding" fi log "" # Confirmation prompt (skip in dry-run mode) if [[ "$DRY_RUN" == false ]] && [[ $files_to_encode -gt 0 ]]; then echo "" echo "Ready to start encoding $files_to_encode files." echo "This will replace original files with H.265/HEVC versions." echo "" read -p "Proceed? [y/N] " confirm if [[ ! "$confirm" =~ ^[Yy]$ ]]; then echo "Cancelled." exit 0 fi echo "" fi # Process each file for file in "${files[@]}"; do ((processed_files++)) # Clean up path for display display_path="${file#./}" log "[$processed_files/$total_files] $display_path" # Check if already has [E] encoded flag if has_encoded_flag "$file"; then log " Skipped: Already has [E] flag" ((skipped_files++)) continue fi # Check if already HEVC (but no [E] flag - add the flag) if [[ "$SKIP_HEVC" == true ]] && is_hevc "$file"; then if [[ "$DRY_RUN" == true ]]; then log " Would add [E] flag: Already HEVC encoded" else new_path=$(add_encoded_flag "$file") log " Added [E] flag: Already HEVC encoded -> $(basename "$new_path")" ((flagged_files++)) fi continue fi # Get original size for space savings calculation original_size=$(stat -c%s "$file") if [[ "$DRY_RUN" == true ]]; then video_info=$(get_video_info "$file") log " Would encode: $video_info ($(format_size $original_size))" continue fi # Get video duration for ETA calculation file_duration=$(get_duration_from_filename "$file") # Encode the file encode_start=$(date +%s) if encode_file "$file"; then encode_end=$(date +%s) new_size=$(stat -c%s "$file") saved=$((original_size - new_size)) total_saved=$((total_saved + saved)) # Track encoded duration for ETA encoded_video_duration=$((encoded_video_duration + file_duration)) # Add [E] flag to filename new_path=$(add_encoded_flag "$file") log " Renamed: $(basename "$new_path")" # Calculate and show ETA if [[ $encoded_video_duration -gt 0 ]] && [[ $total_video_duration -gt 0 ]]; then elapsed_so_far=$((encode_end - start_time)) remaining_video=$((total_video_duration - encoded_video_duration)) if [[ $elapsed_so_far -gt 0 ]]; then # Calculate encoding speed (video seconds per real second) speed=$(echo "scale=2; $encoded_video_duration / $elapsed_so_far" | bc) # Estimate remaining time if (( $(echo "$speed > 0" | bc -l) )); then eta_seconds=$(echo "scale=0; $remaining_video / $speed" | bc) log " ETA: $(format_time $eta_seconds) remaining ($(format_video_duration $remaining_video) of video left, ${speed}x speed)" fi fi fi else ((failed_files++)) log " FAILED - file unchanged" fi echo "" done # Calculate elapsed time end_time=$(date +%s) elapsed=$((end_time - start_time)) hours=$((elapsed / 3600)) minutes=$(((elapsed % 3600) / 60)) seconds=$((elapsed % 60)) # Summary echo "" echo "=========================================" echo " Re-encoding Complete!" echo "=========================================" log " Total files: $total_files" log " Re-encoded: $((processed_files - skipped_files - flagged_files - failed_files))" log " Flagged (HEVC + added [E]): $flagged_files" log " Skipped (already has [E]): $skipped_files" log " Failed: $failed_files" if [[ $encoded_video_duration -gt 0 ]]; then log " Video encoded: $(format_video_duration $encoded_video_duration)" if [[ $elapsed -gt 0 ]]; then avg_speed=$(echo "scale=2; $encoded_video_duration / $elapsed" | bc) log " Average speed: ${avg_speed}x realtime" fi fi if [[ $total_saved -gt 0 ]]; then log " Total space saved: $(format_size $total_saved)" elif [[ $total_saved -lt 0 ]]; then log " Total size increase: $(format_size $((-total_saved)))" fi log " Time elapsed: ${hours}h ${minutes}m ${seconds}s" log " Log saved to: $LOG_FILE" echo "=========================================" echo ""