#!/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) # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # Two locations cleaned in sequence, each through the same routine: # # Ramdisk — only if mountpoint -q confirms it is actually mounted. An unmounted # ramdisk means the underlying directory is the real filesystem, and # cleaning it would delete from disk rather than from tmpfs. # SSD fallback — only if the directory exists. # # Per location: # 1. Path sanity check — refuse anything shallower than two components # 2. Count total and age-eligible files (-mmin +TRANSCODE_MAX_AGE) # 3. One lsof +D call → in-memory open-file map for the whole location # 4. Per eligible file: open → skip and count as active; otherwise rm -f # 5. Remove empty directories older than TRANSCODE_ORPHAN_AGE, never transcoding-temp # # Afterwards, if the ramdisk recovered enough headroom, the symlink is flipped back to # it so new sessions return to RAM. # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Two Independent Conditions, Both Required # Age and open-file state are checked separately and a file must pass both. Age alone # would delete a long-running session's segments; lsof alone cannot see HLS segments, # which are written and closed atomically. Neither signal is sufficient on its own, # which is exactly why both are applied rather than picking the better one. # # Mounted-Only Ramdisk Cleaning # The ramdisk is only cleaned when it is genuinely mounted. If the tmpfs failed to # mount, that same path is an ordinary directory on the array — cleaning it then would # delete real files from disk while believing it was clearing RAM. # # 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. # # find -printf Instead of Per-File stat/basename (2026-07-17) # Same fork-elimination principle as above, applied to the file-processing loop. find # already has to stat() every entry to know it's -type f, so -printf '%s %p' gets the # size for free during the walk instead of a separate stat fork per file. basename was # also forking per file in the dry-run log line -- replaced with parameter expansion # (${file##*/}). Same class of bug found and measured (~28-185x per call) in the arr # cleanup scripts' classification loops the same day. # # 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 # ============================================================================================== # # Location Path Guard # cleanup_location() refuses any path that is not absolute with at least two # components. Neither caller's own check catches a collapsed value — mountpoint -q # returns true for /, and -d is true for both / and /mnt — so the guard lives inside # the function that does the deleting, covering both call sites. # # # 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 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 if read -r _ USED_MB AVAIL_MB <<< "$(disk_df "$RAMDISK_PATH")" && [[ -n "$AVAIL_MB" ]]; then echo " $ICON_RAM Ramdisk: mounted — $(format_mb "$USED_MB") used / $(format_mb "$AVAIL_MB") available ✅" else echo " $ICON_RAM Ramdisk: mounted — size unreadable" fi 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, LOCATION_FAILED 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 files_too_young=0 files_failed=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 LOCATION_TOO_YOUNG=0 LOCATION_FAILED=0 return fi # This function deletes every file under $location past the age gate. Neither caller's # own check catches a collapsed path: mountpoint -q returns true for /, and -d is true # for / and /mnt alike. Require at least two path components so a blank or truncated # RAMDISK_PATH / TRANSCODE_SSD can never point this at a system directory. local _loc_slashes="${location//[^\/]/}" if [[ "$location" != /* || "${#_loc_slashes}" -lt 2 ]]; then error "Refusing to clean unsafe location: '$location' ($label)" notify "Transcode cleanup refused unsafe path on $(hostname): '$location'" \ "Transcode Cleanup" "warning" LOCATION_REMOVED=0 LOCATION_FREED="0B" LOCATION_SKIPPED=0 LOCATION_ACTIVE=0 LOCATION_STREAMING=0 LOCATION_TOO_YOUNG=0 LOCATION_FAILED=0 return fi local file_count eligible_count file_count=$(find "$location" -type f 2>/dev/null | wc -l) eligible_count=$(find "$location" -type f -mmin +"$max_age" 2>/dev/null | wc -l) files_too_young=$(( file_count - eligible_count )) log "$label: $file_count files ($eligible_count eligible, $files_too_young < ${max_age}min)" # Build in-memory open file map — O(1) lookup per file # One lsof call per location — never per file # Note: HLS/remux segments (Live TV, Direct Stream) are written atomically and immediately # closed — lsof will not detect them. The age check is the effective guard for those files. 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. -printf gets size directly from find's own stat() during the walk # instead of a separate stat fork per file (2026-07-17, same fix as the arr cleanup # scripts) — mtime isn't needed here since -mmin above already did the age filtering. local file_size file while read -r file_size 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 if [[ "$DRY_RUN" == true ]]; then log "DRY RUN — would delete: ${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_failed++ )) fi fi done < <(find "$location" -type f -mmin +"$max_age" -printf '%s %p\n' 2>/dev/null) # Remove empty directories older than TRANSCODE_ORPHAN_AGE — but NEVER remove # transcoding-temp. Age gate avoids deleting a session folder ffmpeg just # created but hasn't written its first segment into yet. if [[ "$DRY_RUN" == false ]]; then find "$location" -mindepth 1 -type d -empty -mmin +"${TRANSCODE_ORPHAN_AGE:-30}" \ ! -name "transcoding-temp" -delete 2>/dev/null fi # Format bytes freed local freed_human freed_human=$(format_bytes "$bytes_freed") log "$label — removed $files_removed ($freed_human) | active(fresh): $files_too_young | streaming(lsof): $files_streaming | protected(old+open): $files_active | skipped: $files_skipped | failed: $files_failed" LOCATION_REMOVED=$files_removed LOCATION_FREED=$freed_human LOCATION_SKIPPED=$files_skipped LOCATION_ACTIVE=$files_active LOCATION_STREAMING=$files_streaming LOCATION_TOO_YOUNG=$files_too_young LOCATION_FAILED=$files_failed } # ============================================================================================== # ━━━ Transcode Cleanup ━━━ # ============================================================================================== START=$(date +%s) TOTAL_REMOVED=0 TOTAL_SKIPPED=0 TOTAL_ACTIVE=0 TOTAL_STREAMING=0 TOTAL_TOO_YOUNG=0 TOTAL_FAILED=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 )) TOTAL_TOO_YOUNG=$(( TOTAL_TOO_YOUNG + LOCATION_TOO_YOUNG )) TOTAL_FAILED=$(( TOTAL_FAILED + LOCATION_FAILED )) 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 )) TOTAL_TOO_YOUNG=$(( TOTAL_TOO_YOUNG + LOCATION_TOO_YOUNG )) TOTAL_FAILED=$(( TOTAL_FAILED + LOCATION_FAILED )) 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=$(kb_to_gb "$RAMDISK_USED_KB") 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 Active: $TOTAL_TOO_YOUNG files (< ${TRANSCODE_MAX_AGE}min old — Live TV / Direct Stream segments)" echo "$ICON_RUNNING Streaming: $TOTAL_STREAMING files (open file handle — long-running transcode)" echo "$ICON_SHIELD Protected: $TOTAL_ACTIVE aged files saved by open-file check" echo "$ICON_TRASH Skipped: $TOTAL_SKIPPED files" [[ "$TOTAL_FAILED" -gt 0 ]] && echo "$ICON_ERROR Failed: $TOTAL_FAILED files could not be deleted" echo "$ICON_TIME Duration: $(format_duration $(( END - START )))" if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — no files deleted" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 0 elif [[ "$TOTAL_FAILED" -gt 0 ]]; then warn "Status: $TOTAL_FAILED file(s) failed to delete" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 1 else echo "$ICON_DONE Status: done ✅" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi