#!/bin/bash # ============================================================================================== # ================================= Transcode Cleanup ========================================== # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Removes stale transcode files from both ramdisk and SSD fallback locations. # Called by transcode_management.sh (Orchestrators/) before transcode_manager.sh — # cleanup must run first so the manager sees real active-session usage, not # inflated usage from stale files. Must be fast and non-blocking. # # A file is eligible for deletion only if ALL conditions are true: # 1. Older than TRANSCODE_MAX_AGE minutes (mtime — last write time) # 2. Not currently open by any process (checked via lsof pre-built map) # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # lsof Called Once, Not Per File # On a busy Live TV system the ramdisk contains thousands of HLS segment files. # Calling lsof once per file creates thousands of subprocess calls every 7 minutes. # lsof is called once per location to build a complete open-file map. All subsequent # checks are O(1) lookups against that map — thousands of files, one lsof call. # # No Session-Aware Cleanup # ffmpeg generates folder names independently of the media server API session IDs. # There is no reliable correlation between API session IDs and transcoding-temp # subfolder names. Attempting to correlate them would falsely treat active sessions # as ended. lsof is the correct check — if ffmpeg has a file open, it is active # regardless of folder naming or session state. # # transcoding-temp Is Never Deleted # If cleanup removes the empty transcoding-temp folder from the ramdisk, Emby # searches all accessible paths for an existing one, finds the SSD fallback version, # and routes all new sessions there until Emby restarts. The directory is excluded # from find by name — protected even when completely empty. # # Post-Cleanup Flip-Back # After removing stale files, checks whether ramdisk usage dropped below # RAMDISK_LOW_GB. If so — and symlink currently points at SSD — triggers a # flip back to ramdisk. This is the recovery path; the manager handles # the fill-up path. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # Wait Lock # acquire_lock "wait" — waits if a previous cleanup run is still active rather # than exiting. The caller's 7-minute interval can overlap on a slow system. # # lsof Timeout # lsof call capped at 15 seconds per location — prevents blocking indefinitely # on a system with many open files. # # transcoding-temp Guard # `! -name "transcoding-temp"` in the find command — protected unconditionally. # # Silent by Default # Runs every 7 minutes — must not produce noise when healthy. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # host*.conf # # HOST*_RAMDISK_PATH / HOST*_TRANSCODE_SSD / HOST*_RAMDISK_LOW_GB # Aliased by detect_hosts() → RAMDISK_PATH / TRANSCODE_SSD / RAMDISK_LOW_GB. # # master.conf # # TRANSCODE_MAX_AGE # Minutes before an inactive transcode file is eligible for deletion. (default: 20) # # TRANSCODE_ORPHAN_AGE # Minutes for orphan folder detection. (default: 30) # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # transcode_cleanup.sh # Remove stale files from ramdisk and SSD. Check for flip-back opportunity. # # transcode_cleanup.sh --dry-run # Show which files would be deleted. No deletions, no flip. # # transcode_cleanup.sh --status # Show current file counts, ages, and open-file status per location. # # transcode_cleanup.sh --log # Verbose per-file output including age, open status, and deletion result. # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" parse_args "$@" STATE_FILE="${TRANSCODE_STATE_FILE:-${STATE_DIR:-/tmp}/transcode_state.db}" # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi validate_unraid_cmd \ "/usr/local/emhttp/plugins/dynamix/scripts/notify" \ "" "" \ "unRAID notify script" || warn "unRAID notify script not found — native notifications disabled" acquire_lock "wait" # detect_hosts() sets MY_ID and aliases RAMDISK_PATH, TRANSCODE_SSD, RAMDISK_LOW_GB detect_hosts # lsof availability check LSOF_AVAILABLE=false if command -v lsof >/dev/null 2>&1; then LSOF_AVAILABLE=true log "lsof available — active file check enabled" else warn "lsof not available — active file check skipped, all aged files eligible for deletion" fi log "Identity: $MY_ID ($LOCAL_SERVER_NAME)" log "$ICON_GEAR Config: max-age=${TRANSCODE_MAX_AGE}min orphan-age=${TRANSCODE_ORPHAN_AGE}min flip-back-below=${RAMDISK_LOW_GB}GB" log "$ICON_RAM Locations: ramdisk=$RAMDISK_PATH ssd=$TRANSCODE_SSD" # ============================================================================================== # ━━━ Status ━━━ # ============================================================================================== if [[ "$SHOW_STATUS" == true ]]; then echo "" echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_RAM Ramdisk: $RAMDISK_PATH" echo "$ICON_DISK SSD fallback: $TRANSCODE_SSD" echo "$ICON_TRASH Max age: ${TRANSCODE_MAX_AGE} minutes" echo "$ICON_TRASH Orphan age: ${TRANSCODE_ORPHAN_AGE} minutes" echo "$ICON_RAM Flip at: ${RAMDISK_LOW_GB}GB" echo "$ICON_GEAR lsof check: $LSOF_AVAILABLE" echo "$ICON_GEAR Dry Run: $DRY_RUN" echo "" if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then USAGE=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $3}') AVAIL=$(df -BG "$RAMDISK_PATH" | awk 'NR==2 {print $4}') echo " $ICON_RAM Ramdisk: mounted — $USAGE used / $AVAIL available ✅" else echo " $ICON_RAM Ramdisk: not mounted" fi CURRENT_TARGET=$(grep "^current_target=" "$STATE_FILE" 2>/dev/null | cut -d= -f2) echo " $ICON_LINK Current target: ${CURRENT_TARGET:-unknown}" echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted" # ============================================================================================== # ── CLEANUP FUNCTION ────────────────────────────────────────────────────────────────────────── # ============================================================================================== # Scans a location and removes eligible files. # Calls lsof ONCE per location — builds in-memory OPEN_FILES_MAP for O(1) lookup. # Returns via LOCATION_REMOVED, LOCATION_FREED, LOCATION_SKIPPED, LOCATION_ACTIVE cleanup_location() { local location="$1" label="$2" max_age="$3" local files_removed=0 bytes_freed=0 files_skipped=0 files_active=0 files_streaming=0 if [[ ! -d "$location" ]]; then log "$label does not exist — skipping" LOCATION_REMOVED=0 LOCATION_FREED="0B" LOCATION_SKIPPED=0 LOCATION_ACTIVE=0 LOCATION_STREAMING=0 return fi local file_count file_count=$(find "$location" -type f 2>/dev/null | wc -l) log "$label: $file_count files to scan (age threshold: ${max_age}min)" # Build in-memory open file map — O(1) lookup per file # One lsof call per location — never per file declare -A OPEN_FILES_MAP if [[ "$LSOF_AVAILABLE" == true ]]; then log "Building open file map for $label..." while IFS= read -r open_file; do [[ -n "$open_file" ]] && OPEN_FILES_MAP["$open_file"]=1 done < <(timeout 15 lsof +D "$location" 2>/dev/null | awk 'NR>1 {print $9}' | sort -u) files_streaming=${#OPEN_FILES_MAP[@]} log "$files_streaming files currently open in $label" fi # Process aged files while IFS= read -r file; do [[ -z "$file" ]] && continue # O(1) open file check — in-memory map if [[ -n "${OPEN_FILES_MAP[$file]:-}" ]]; then (( files_active++ )) log "Skipping open file: $file" continue fi local file_size file_size=$(stat -c%s "$file" 2>/dev/null || echo 0) if [[ "$DRY_RUN" == true ]]; then log "DRY RUN — would delete: $(basename "$file")" (( files_skipped++ )) else if rm -f "$file" 2>/dev/null; then (( files_removed++ )) bytes_freed=$(( bytes_freed + file_size )) log "Deleted: $file" else warn "Could not delete: $file" (( files_skipped++ )) fi fi done < <(find "$location" -type f -mmin +"$max_age" 2>/dev/null) # Remove empty directories — but NEVER remove transcoding-temp # transcoding-temp must always exist on ramdisk so Emby finds it there first if [[ "$DRY_RUN" == false ]]; then find "$location" -mindepth 1 -type d -empty \ ! -name "transcoding-temp" -delete 2>/dev/null fi # Format bytes freed local freed_human if (( bytes_freed > 1073741824 )); then freed_human=$(awk "BEGIN {printf \"%.1fGB\", $bytes_freed / 1073741824}") elif (( bytes_freed > 1048576 )); then freed_human=$(awk "BEGIN {printf \"%.1fMB\", $bytes_freed / 1048576}") elif (( bytes_freed > 0 )); then freed_human="${bytes_freed}B" else freed_human="0B" fi log "$label — removed $files_removed files ($freed_human freed) | streaming: $files_streaming | aged+open: $files_active | skipped: $files_skipped" LOCATION_REMOVED=$files_removed LOCATION_FREED=$freed_human LOCATION_SKIPPED=$files_skipped LOCATION_ACTIVE=$files_active LOCATION_STREAMING=$files_streaming } # ============================================================================================== # ━━━ Transcode Cleanup ━━━ # ============================================================================================== START=$(date +%s) TOTAL_REMOVED=0 TOTAL_SKIPPED=0 TOTAL_ACTIVE=0 TOTAL_STREAMING=0 RAMDISK_FREED="0B" SSD_FREED="0B" # Cleanup ramdisk if mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then cleanup_location "$RAMDISK_PATH" "Ramdisk" "$TRANSCODE_MAX_AGE" TOTAL_REMOVED=$(( TOTAL_REMOVED + LOCATION_REMOVED )) TOTAL_SKIPPED=$(( TOTAL_SKIPPED + LOCATION_SKIPPED )) TOTAL_ACTIVE=$(( TOTAL_ACTIVE + LOCATION_ACTIVE )) TOTAL_STREAMING=$(( TOTAL_STREAMING + LOCATION_STREAMING )) RAMDISK_FREED=$LOCATION_FREED else log "Ramdisk not mounted — skipping ramdisk cleanup" fi # Cleanup SSD fallback if [[ -d "$TRANSCODE_SSD" ]]; then cleanup_location "$TRANSCODE_SSD" "SSD fallback" "$TRANSCODE_MAX_AGE" TOTAL_REMOVED=$(( TOTAL_REMOVED + LOCATION_REMOVED )) TOTAL_SKIPPED=$(( TOTAL_SKIPPED + LOCATION_SKIPPED )) TOTAL_ACTIVE=$(( TOTAL_ACTIVE + LOCATION_ACTIVE )) TOTAL_STREAMING=$(( TOTAL_STREAMING + LOCATION_STREAMING )) SSD_FREED=$LOCATION_FREED else log "SSD fallback not found — skipping SSD cleanup" fi # Post-cleanup — check if ramdisk recovered enough to flip symlink back to ramdisk if [[ "$DRY_RUN" == false ]] && mountpoint -q "$RAMDISK_PATH" 2>/dev/null; then RAMDISK_USED_KB=$(df "$RAMDISK_PATH" --output=used 2>/dev/null | tail -1 | tr -d ' ') RAMDISK_USED_GB=$(awk "BEGIN {printf \"%.2f\", ${RAMDISK_USED_KB:-0} / 1048576}") LOW_RECOVERED=$(awk "BEGIN {print ($RAMDISK_USED_GB < $RAMDISK_LOW_GB) ? 1 : 0}") CURRENT_TARGET=$(grep "^current_target=" "$STATE_FILE" 2>/dev/null | cut -d= -f2) if [[ "$LOW_RECOVERED" == "1" && "$CURRENT_TARGET" == "$TRANSCODE_SSD" ]]; then echo "Ramdisk has space after cleanup (${RAMDISK_USED_GB}GB < ${RAMDISK_LOW_GB}GB) — triggering manager to flip back" bash "$SCRIPT_DIR/transcode_manager.sh" --no-log fi fi END=$(date +%s) # ============================================================================================== # ━━━ Summary ━━━ # ============================================================================================== echo "━━━━━ $ICON_SUMMARY TRANSCODE CLEANUP SUMMARY ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_RAM Ramdisk freed: $RAMDISK_FREED" echo "$ICON_DISK SSD freed: $SSD_FREED" echo "$ICON_TRASH Removed: $TOTAL_REMOVED files" echo "$ICON_RUNNING Streaming: $TOTAL_STREAMING files (open by Emby — untouched)" echo "$ICON_SHIELD Protected: $TOTAL_ACTIVE aged files saved by open-file check" echo "$ICON_TRASH Skipped: $TOTAL_SKIPPED files" echo "$ICON_TIME Duration: $(format_duration $(( END - START )))" if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — no files deleted" else echo "$ICON_DONE Status: done ✅" fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"