#!/bin/bash # ============================================================================================== # ========================== Arr Download Orphan Cleaner ======================================= # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Clears orphaned completed downloads out of the SABnzbd Completed folders that Sonarr and # Radarr import from. Anything sitting there that the arr's queue no longer references is an # orphan — the arr will never touch it again on its own, so without this script the folder # only ever grows. # # Built 2026-07-26 after exactly that: 755G of orphaned completed TV downloads (oldest from # 2022) had silently accumulated and filled the cache pool to 89%. Every existing cleaner # covers the library side (sonarr_cleanup.sh walks SONARR_TV_ROOT etc.) — nothing covered # the download side. This is that missing piece, using the same triage that recovered the # pool that day. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # Runs Sonarr then Radarr, sequentially. Per arr, every top-level entry in the configured # download dir is classified: # # TRACKED — basename matches a queue record's outputPath or title → leave it alone, # the arr still knows about it # RECENT — mtime under DOWNLOAD_ORPHAN_AGE days → skip, may be mid-import # JUNK — no video file over DOWNLOAD_ORPHAN_MIN_VIDEO_MB → delete (par2 debris, # samples, failed/never-extracted archives — the arr can't import these, # and if the content is still wanted its own missing-search re-grabs it) # REDUNDANT — parse API matches it AND the library already has every episode / the # movie file → delete (the arr already refused it as not-an-upgrade) # IMPORTABLE — parse API matches it but the library is missing episodes / the movie # → trigger DownloadedEpisodesScan/DownloadedMoviesScan on the folder and # leave it; whatever imports gets swept as REDUNDANT next run, whatever # the arr rejects (XEM-blocked, season-span files) stays HELD for a human # UNMATCHED — parse API can't match it (series/movie not in the arr) → delete. Past the # age gate an entry the arr cannot even name is not going to import: if the # title is in the library and monitored, clearing it lets the arr search a # copy it can actually parse; if it is not in the library, nothing is # tracking it and it is dead weight either way. Guarded — see Non-Empty # Library Requirement below # HELD — IMPORTABLE entries the arr keeps refusing (XEM-blocked, season-span # files), and everything skipped by a guard → report only, human call # # The queue fetch is a hard gate: if it fails, the whole arr is skipped — with no queue # there is no way to tell tracked from orphaned, and guessing means deleting active imports. # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Classify Before Acting # Every entry is placed in exactly one class before anything is deleted, and each class # has its own justification. Nothing is removed because it merely looked unwanted — it is # removed because it matched a category whose deletion rationale is written down above. # # The Queue Is the Source of Truth # Tracked-vs-orphaned is decided by the arr's own queue, never inferred from filenames or # timestamps. If the queue cannot be read, the arr is skipped entirely rather than # falling back to a weaker signal — a guess here deletes an active import. # # Deleting Is Recoverable, Deleting Wrong Is Not # The classes that get deleted are ones the arr can re-acquire: junk it could never # import, content the library already has, and entries it cannot even name. Anything # whose loss would be permanent or ambiguous is held and reported for a human instead. # # Abnormal Volume Means Broken Input # The delete cap exists because the realistic failure mode is bad input, not bad logic — # a partial queue fetch classifies live downloads as orphans, and the only visible # symptom is an unusually large delete total. The cap turns that into a stop. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # Root Enforcement # Download folders are written by container users; removing them requires root. # # Lock Acquisition # acquire_lock "wait" with an EXIT trap releasing all locks, so an interrupted run never # strands a lock and the daily orchestrator is never silently skipped. # # Host Detection # detect_hosts() runs before any HOST*_-prefixed download dir is resolved. # # DOWNLOAD_ORPHAN_CLEANER_ENABLED Toggle # Master switch — exits cleanly when disabled. # # Download Path Restriction # The download dir must exist and live under /mnt/. Anything else is refused rather # than walked, so a blank or malformed path can never point the scan at the filesystem # root or a system directory. # # Queue Fetch Hard Gate # The arr is skipped entirely if its queue cannot be read. Without the queue there is no # way to distinguish tracked from orphaned, and guessing deletes active imports. # # Age Gate # Nothing under DOWNLOAD_ORPHAN_AGE days is touched, so an entry mid-import is never a # deletion candidate regardless of how it classifies. # # Deletion Class Restriction # Only JUNK, parse-verified REDUNDANT and UNMATCHED are deleted. IMPORTABLE entries and # anything a guard has held are reported, never removed. # # Non-Empty Library Requirement # UNMATCHED deletions require the arr to report a non-empty library. An empty or # restoring database answers every parse with "no match", which would condemn the whole # download dir. The library is queried directly rather than inferred from this run's own # match rate — a small batch that is legitimately all-unmatched is normal once daily runs # have caught up, and would otherwise read as a broken database. # # Delete Volume Cap # A run total over DOWNLOAD_ORPHAN_MAX_DELETE_GB aborts the delete pass and notifies. # --i-know-what-im-doing overrides it for a deliberate first run against a known backlog. # # Dry Run Support # --dry-run classifies everything and reports, deleting and importing nothing. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # host*.conf (resolved per host after detect_hosts()) # # HOST*_SONARR_DOWNLOAD_DIR / HOST*_RADARR_DOWNLOAD_DIR # Host-side path to the arr's completed-download folder. Absent means that arr's # cleanup is skipped, not an error. # # HOST*_SONARR_DOWNLOAD_CONTAINER_DIR / HOST*_RADARR_DOWNLOAD_CONTAINER_DIR # The same folder as the arr container sees it — used when triggering the # DownloadedEpisodesScan / DownloadedMoviesScan path. # # SONARR_URL / SONARR_API_KEY / RADARR_URL / RADARR_API_KEY # Aliased by detect_hosts(). A missing URL or key skips that arr. # # master.conf # # DOWNLOAD_ORPHAN_CLEANER_ENABLED # Master toggle (default: true) # # DOWNLOAD_ORPHAN_AGE # Days before an entry is eligible at all — younger entries may be mid-import # (default: 7) # # DOWNLOAD_ORPHAN_MIN_VIDEO_MB # An entry with no video file above this size is JUNK (default: 50). Sonarr/Radarr only. # # DOWNLOAD_ORPHAN_MIN_AUDIO_MB # The same test for Lidarr (default: 2). Separate because a 50M floor would mark # every album folder as JUNK — single tracks rarely reach it. # # DOWNLOAD_ORPHAN_KEEP_MARKER # A file with this name inside a download folder pins it — the folder is never # classified or deleted (default: .vv-keep). For lossless rips the library holds # only at lower quality, which REDUNDANT would otherwise sweep. # # DOWNLOAD_ORPHAN_MAX_DELETE_GB # Per-run delete budget in GB (default: 100). A backlog above this is drained # safest-first (JUNK, then REDUNDANT, then UNMATCHED) up to the budget, and the # remainder is deferred to the next run rather than aborting the pass. # # SONARR_EXTENSIONS / RADARR_EXTENSIONS # Video extensions used to decide whether an entry contains real media # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # arr_download_orphan_cleaner.sh — daily orchestrator entry # arr_download_orphan_cleaner.sh --dry-run — classify and report only # arr_download_orphan_cleaner.sh --status — show config and exit # arr_download_orphan_cleaner.sh --i-know-what-im-doing — bypass MAX_DELETE_GB budget # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" parse_destructive_flags "$@" parse_args "${FILTERED_ARGS[@]}" # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi for cmd in curl jq; do if ! command -v "$cmd" >/dev/null 2>&1; then error "$cmd not found — required" exit 1 fi done detect_hosts if [[ "${DOWNLOAD_ORPHAN_CLEANER_ENABLED:-false}" != true ]]; then info "DOWNLOAD_ORPHAN_CLEANER_ENABLED=false — skipping" exit 0 fi DOWNLOAD_ORPHAN_AGE="${DOWNLOAD_ORPHAN_AGE:-7}" DOWNLOAD_ORPHAN_MIN_VIDEO_MB="${DOWNLOAD_ORPHAN_MIN_VIDEO_MB:-50}" DOWNLOAD_ORPHAN_MIN_AUDIO_MB="${DOWNLOAD_ORPHAN_MIN_AUDIO_MB:-2}" DOWNLOAD_ORPHAN_KEEP_MARKER="${DOWNLOAD_ORPHAN_KEEP_MARKER:-.vv-keep}" DOWNLOAD_ORPHAN_MAX_DELETE_GB="${DOWNLOAD_ORPHAN_MAX_DELETE_GB:-100}" if [[ "${SHOW_STATUS:-false}" == true ]]; then echo "━━━━━ $ICON_SUMMARY DOWNLOAD ORPHAN CLEANER STATUS ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_GEAR Enabled: ${DOWNLOAD_ORPHAN_CLEANER_ENABLED}" echo "$ICON_TIME Age gate: ${DOWNLOAD_ORPHAN_AGE}d" echo "$ICON_DISK Junk threshold: ${DOWNLOAD_ORPHAN_MIN_VIDEO_MB}M video / ${DOWNLOAD_ORPHAN_MIN_AUDIO_MB}M audio" echo "$ICON_SHIELD Delete cap: ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G" echo "$ICON_SHIELD Keep marker: ${DOWNLOAD_ORPHAN_KEEP_MARKER}" for arr in SONARR RADARR LIDARR; do dir_var="${MY_ID}_${arr}_DOWNLOAD_DIR" echo "$ICON_CLEAN ${arr}: ${!dir_var:-}" done exit 0 fi acquire_lock "wait" trap "_release_all_locks" EXIT AGE_CUTOFF=$(( $(date +%s) - DOWNLOAD_ORPHAN_AGE * 86400 )) TOTAL_DELETED=0 TOTAL_DELETED_MB=0 TOTAL_HELD=0 TOTAL_DEFERRED=0 TOTAL_KEPT=0 TOTAL_SCANS=0 echo "━━━━━ $ICON_CLEAN DOWNLOAD ORPHAN CLEANER ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" [[ "$DRY_RUN" == true ]] && echo "$ICON_SKIP DRY RUN — nothing will be deleted or imported" for arr in sonarr radarr lidarr; do api_ver="v3"; [[ "$arr" == "lidarr" ]] && api_ver="v1" url_var="${arr^^}_URL"; key_var="${arr^^}_API_KEY" arr_url="${!url_var:-}"; arr_key="${!key_var:-}" dir_var="${MY_ID}_${arr^^}_DOWNLOAD_DIR" cdir_var="${MY_ID}_${arr^^}_DOWNLOAD_CONTAINER_DIR" dl_dir="${!dir_var:-}"; container_dir="${!cdir_var:-}" if [[ -z "$arr_url" || -z "$arr_key" || -z "$dl_dir" ]]; then info "${arr^} download cleanup not configured on $MY_ID — skipping" continue fi if [[ "$dl_dir" != /mnt/* || ! -d "$dl_dir" ]]; then warn "${arr^} download dir invalid or missing: $dl_dir — skipping" continue fi echo "" echo "━━━ $ICON_SYNC ${arr^} — $dl_dir ━━━" ver_var="${arr^^}_VERSION_MAJOR" check_arr_version "$arr_url" "$arr_key" "$api_ver" "${!ver_var}" "${arr^}" || { warn "${arr^} version check failed — skipping this arr" continue } # min_mb is per-arr because the JUNK test is "contains no real media file". A 50MB floor # is right for video and catastrophic for audio — most single tracks never reach it, so # every music folder would classify as JUNK and be deleted regardless of import state. case "$arr" in sonarr) queue_endpoint="queue?pageSize=1000&includeUnknownSeriesItems=true" exts_var="SONARR_EXTENSIONS" scan_command="DownloadedEpisodesScan" library_endpoint="series" min_mb="$DOWNLOAD_ORPHAN_MIN_VIDEO_MB" ;; radarr) queue_endpoint="queue?pageSize=1000&includeUnknownMovieItems=true" exts_var="RADARR_EXTENSIONS" scan_command="DownloadedMoviesScan" library_endpoint="movie" min_mb="$DOWNLOAD_ORPHAN_MIN_VIDEO_MB" ;; lidarr) queue_endpoint="queue?pageSize=1000&includeUnknownArtistItems=true" exts_var="LIDARR_EXTENSIONS" scan_command="DownloadedAlbumsScan" library_endpoint="artist" min_mb="$DOWNLOAD_ORPHAN_MIN_AUDIO_MB" ;; esac QUEUE_JSON=$(arr_api "$arr_url" "$arr_key" "$api_ver" "$queue_endpoint" "${arr^}") || { error "${arr^} queue fetch failed — cannot tell tracked from orphaned, skipping this arr" continue } declare -A PROTECTED=() while IFS= read -r name; do [[ -n "$name" ]] && PROTECTED["$name"]=1 done < <(echo "$QUEUE_JSON" | jq -r '.records[] | (.outputPath // empty | split("/") | last), (.title // empty)') eval "arr_exts=(\"\${${exts_var}[@]}\")" DELETE_PATHS=() DELETE_SIZES=() DELETE_LABELS=() SCAN_PATHS=() UNMATCHED_PATHS=() UNMATCHED_SIZES=() arr_tracked=0; arr_recent=0; arr_held=0; arr_delete_mb=0; arr_kept=0 while IFS= read -r entry; do base="${entry##*/}" # An operator keep-marker outranks every verdict below. Needed because REDUNDANT only # asks "does the library hold this album", not "at what quality" — a lossless rip whose # library copy is MP3 is redundant by that test and would be swept on the next run. # The marker is a file inside the folder rather than a conf list so it survives renames # and cannot drift out of sync with what is actually on disk. if [[ -e "$entry/$DOWNLOAD_ORPHAN_KEEP_MARKER" ]]; then arr_kept=$((arr_kept + 1)) continue fi if [[ -n "${PROTECTED[$base]:-}" ]]; then arr_tracked=$((arr_tracked + 1)) continue fi mtime=$(stat -c %Y "$entry" 2>/dev/null) || continue if (( mtime > AGE_CUTOFF )); then arr_recent=$((arr_recent + 1)) continue fi # JUNK means "holds no real media". That verdict is only as good as the extension # list, and a missing extension turns real content into a delete — 2026-08-21 the # audio list had no "wv", which classified 23 folders of WavPack lossless (1.5G per # file) as junk. So a folder with large files that are merely *unrecognised* is held # for review, never deleted; only a folder with nothing big in it at all is junk. has_media=false big_unknown=0 while IFS= read -r f; do if has_extension "$f" "${arr_exts[@]}"; then has_media=true break fi big_unknown=$((big_unknown + 1)) done < <(find "$entry" -type f -size +"${min_mb}"M 2>/dev/null) size_mb=$(du -sm "$entry" 2>/dev/null | cut -f1) size_mb=${size_mb:-0} if [[ "$has_media" == false ]] && (( big_unknown > 0 )); then warn " no recognised media, but $big_unknown large file(s) of unknown type — holding: $base" arr_held=$((arr_held + 1)) continue fi if [[ "$has_media" == false ]]; then DELETE_PATHS+=("$entry") DELETE_SIZES+=("$size_mb") DELETE_LABELS+=("JUNK") arr_delete_mb=$((arr_delete_mb + size_mb)) continue fi enc_title=$(jq -rn --arg t "$base" '$t|@uri') parse=$(arr_api "$arr_url" "$arr_key" "$api_ver" "parse?title=${enc_title}" "${arr^}") || { warn " parse failed for: $base — holding" arr_held=$((arr_held + 1)) continue } case "$arr" in sonarr) matched=$(echo "$parse" | jq '(.series != null) and ((.episodes | length) > 0)') missing=$(echo "$parse" | jq '[.episodes[]? | select(.hasFile == false)] | length') ;; radarr) # Radarr's parse never populates hasFile — movieFileId is the reliable signal matched=$(echo "$parse" | jq '.movie != null') missing=$(echo "$parse" | jq 'if (.movie.movieFileId // 0) > 0 then 0 else 1 end') ;; lidarr) # Lidarr's parse returns albums with statistics:null, so the track count has # to be read back from album/{id} — the same shape of gap as Radarr's hasFile. matched=$(echo "$parse" | jq '(.artist != null) and ((.albums | length) > 0)') missing=1 if [[ "$matched" == true ]]; then album_id=$(echo "$parse" | jq -r '.albums[0].id // empty') if [[ -z "$album_id" ]]; then warn " parse matched but returned no album id: $base — holding" arr_held=$((arr_held + 1)) continue fi album_json=$(arr_api "$arr_url" "$arr_key" "$api_ver" "album/$album_id" "${arr^}") || { warn " album lookup failed for: $base — holding" arr_held=$((arr_held + 1)) continue } missing=$(echo "$album_json" | jq 'if ((.statistics.trackFileCount // 0) > 0) then 0 else 1 end') fi ;; esac if [[ "$matched" != true ]]; then UNMATCHED_PATHS+=("$entry") UNMATCHED_SIZES+=("$size_mb") elif (( missing == 0 )); then DELETE_PATHS+=("$entry") DELETE_SIZES+=("$size_mb") DELETE_LABELS+=("REDUNDANT") arr_delete_mb=$((arr_delete_mb + size_mb)) else SCAN_PATHS+=("$base") arr_held=$((arr_held + 1)) fi done < <(find "$dl_dir" -mindepth 1 -maxdepth 1 2>/dev/null) # An arr with an empty or still-restoring database answers every parse with "no match", # which would turn the whole download dir into UNMATCHED and delete it. Confirm the # library actually holds titles before trusting a no-match to mean what it says. This # has to be asked of the arr directly — inferring health from the run's own matches # fails on a small batch that is legitimately all-unmatched, which is the normal case # once daily runs have caught up. if (( ${#UNMATCHED_PATHS[@]} > 0 )); then library_count=$(arr_api "$arr_url" "$arr_key" "$api_ver" "$library_endpoint" "${arr^}" | jq 'length' 2>/dev/null) if [[ ! "$library_count" =~ ^[0-9]+$ ]] || (( library_count == 0 )); then warn " ${arr^}: library reports ${library_count:-no} titles — cannot trust 'no match', holding ${#UNMATCHED_PATHS[@]} unmatched" arr_held=$((arr_held + ${#UNMATCHED_PATHS[@]})) else for i in "${!UNMATCHED_PATHS[@]}"; do DELETE_PATHS+=("${UNMATCHED_PATHS[$i]}") DELETE_SIZES+=("${UNMATCHED_SIZES[$i]}") DELETE_LABELS+=("UNMATCHED") arr_delete_mb=$((arr_delete_mb + UNMATCHED_SIZES[i])) done fi fi # The cap is a per-run risk budget, not a reason to do nothing. Aborting the whole pass # once the backlog exceeds it is self-defeating: the backlog can never shrink below the # cap on its own, so every later run aborts too and the pool fills anyway (exactly how # 347G accumulated here by 2026-08-21). Delete in ascending order of risk instead, stop # at the cap, and defer the rest to the next run so a backlog drains over days. # # Live downloads are already protected by DOWNLOAD_ORPHAN_AGE, not by this cap — anything # in flight is younger than the age gate and never reaches classification. That is what # makes draining safe: the partial-queue-data case the cap was written for cannot put a # still-downloading entry in these arrays. cap_mb=$((DOWNLOAD_ORPHAN_MAX_DELETE_GB * 1024)) cap_active=true [[ "$I_KNOW" == true || "$DRY_RUN" == true ]] && cap_active=false arr_deferred=0; arr_deferred_mb=0; arr_run_mb=0 if [[ "$cap_active" == true ]] && (( arr_delete_mb > cap_mb )); then warn " ${arr^}: $((arr_delete_mb / 1024))G classified vs ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G cap — deleting safest-first up to the cap, deferring the rest" notify "${arr^} download orphan backlog is $((arr_delete_mb / 1024))G on $(hostname), above the ${DOWNLOAD_ORPHAN_MAX_DELETE_GB}G per-run cap. Draining safest-first; the remainder follows on later runs. Re-run with --i-know-what-im-doing to clear it in one pass." \ "Download Orphan Cleaner" "warning" fi # JUNK first (no media at all), then REDUNDANT (parse-verified already in the library), # then UNMATCHED last — it rests on "the arr does not know this title", the weakest of # the three signals, so it is the first thing the cap defers. for pass in JUNK REDUNDANT UNMATCHED; do for i in "${!DELETE_PATHS[@]}"; do [[ "${DELETE_LABELS[$i]}" == "$pass" ]] || continue entry="${DELETE_PATHS[$i]}" if [[ "$cap_active" == true ]] && (( arr_run_mb + DELETE_SIZES[i] > cap_mb )); then arr_deferred=$((arr_deferred + 1)) arr_deferred_mb=$((arr_deferred_mb + DELETE_SIZES[i])) continue fi if [[ "$DRY_RUN" == true ]]; then echo " $ICON_SKIP would delete [${DELETE_LABELS[$i]}]: ${entry##*/} (${DELETE_SIZES[$i]}M)" else echo " $ICON_TRASH deleting [${DELETE_LABELS[$i]}]: ${entry##*/} (${DELETE_SIZES[$i]}M)" rm -rf "$entry" fi arr_run_mb=$((arr_run_mb + DELETE_SIZES[i])) TOTAL_DELETED=$((TOTAL_DELETED + 1)) TOTAL_DELETED_MB=$((TOTAL_DELETED_MB + DELETE_SIZES[i])) done done if (( arr_deferred > 0 )); then echo " $ICON_WARN ${arr^}: deferred $arr_deferred entries ($((arr_deferred_mb / 1024))G) to the next run — cap reached" TOTAL_DEFERRED=$((TOTAL_DEFERRED + arr_deferred)) fi for base in "${SCAN_PATHS[@]}"; do if [[ -z "$container_dir" ]]; then echo " $ICON_WARN IMPORTABLE but ${cdir_var} not set — holding: $base" continue fi if [[ "$DRY_RUN" == true ]]; then echo " $ICON_SKIP would trigger $scan_command: $base" else echo " $ICON_RUN triggering $scan_command: $base" payload=$(jq -nc --arg n "$scan_command" --arg p "$container_dir/$base" '{name: $n, path: $p, importMode: "Move"}') curl -sf --max-time 30 -X POST \ -H "X-Api-Key: $arr_key" -H "Content-Type: application/json" \ -d "$payload" "$arr_url/api/v3/command" >/dev/null \ || warn " $scan_command trigger failed for: $base" TOTAL_SCANS=$((TOTAL_SCANS + 1)) fi done echo " $ICON_SUMMARY ${arr^}: $arr_tracked tracked, $arr_kept kept, $arr_recent recent, $((${#DELETE_PATHS[@]} - arr_deferred)) deleted ($((arr_run_mb / 1024))G), $arr_deferred deferred ($((arr_deferred_mb / 1024))G), ${#SCAN_PATHS[@]} import scans, $arr_held held" TOTAL_HELD=$((TOTAL_HELD + arr_held)) TOTAL_KEPT=$((TOTAL_KEPT + arr_kept)) unset PROTECTED done echo "" echo "━━━━━ $ICON_DONE SUMMARY ━━━━━" echo "$ICON_TRASH Deleted: $TOTAL_DELETED ($((TOTAL_DELETED_MB / 1024))G)" echo "$ICON_RUN Import scans: $TOTAL_SCANS" echo "$ICON_WARN Held: $TOTAL_HELD" echo "$ICON_SKIP Deferred: $TOTAL_DEFERRED" echo "$ICON_SHIELD Kept (marker): $TOTAL_KEPT" # Held alone never notifies — there is always something awaiting review, and on a daily # schedule that would be a notification every morning saying nothing happened. if [[ "$DRY_RUN" != true ]] && (( TOTAL_DELETED > 0 )); then notify "Download orphan cleaner on $(hostname): deleted $TOTAL_DELETED orphans ($((TOTAL_DELETED_MB / 1024))G), triggered $TOTAL_SCANS import scans, $TOTAL_HELD held for review" \ "Download Orphan Cleaner" "normal" fi