#!/bin/bash # ============================================================================================== # ================================= Media Cleaner ============================================== # ============================================================================================== # # PURPOSE # ───────────────────────────────────────────────────────────────────────────── # Remove scene debris, tool artifacts, and unsafe files from media shares # before the orphan scan — so arr cleanup only encounters actual media files. # Runs second in the daily window, after permissions and before arr cleanup. # Two profiles — anime and media — each with their own folder list and patterns. # # ============================================================================================== # OPERATIONAL MODEL # ============================================================================================== # # Profiles: # anime — cleans ANIME_CLEAN_FOLDERS using ANIME_FILE_PATTERNS # media — cleans MEDIA_CLEAN_FOLDERS using MEDIA_FILE_PATTERNS (adds *.iso *.lrc) # # Scene debris — left by scene releases and download clients: # *.sfv *.md5 *.sha1 — checksums — useless post-download # *.nzb *.torrent — download files left by clients # *.url *.lnk *.info *.diz — scene metadata # *.sample* *.proof* — scene samples — never needed in library # *sync-conflict* — Syncthing conflict copies # *.rar *.zip *.7z *.ace — archives — source not needed after extraction # *.r00-*.r09 *.srr — multi-part rar segments and repair files # *.001 *.002 *.003 — split archive parts # *.gz *.tar *.bz2 — linux archives # # Tool artifacts — incomplete or stale files from download clients: # *.!ut *.!qB — uTorrent / qBittorrent incomplete markers # *.crdownload *.opdownload — Chrome / Opera incomplete downloads # *.part — partial download files # # Unsafe files — executables that should never appear in a media folder: # *.exe *.scr *.com — Windows executables # *.bat *.cmd *.vbs *.ps1 — Windows scripts # *.msi *.dll *.sys — Windows system files # *.sh — shell scripts in media folders = suspicious # # Media profile also removes: *.iso *.lrc # # ============================================================================================== # DESIGN PRINCIPLES # ============================================================================================== # # Pre-Scan Cleanup # Runs before arr cleanup scripts so orphan detection only encounters actual # media files. Scene debris and download artifacts would otherwise appear as # untracked files and inflate false-positive orphan counts. # # Profile Separation # Anime and media share different cleanup patterns because their content # differs. *.lrc (lyrics) and *.iso belong in media cleanup but not anime. # Separate profiles prevent cross-contamination of rules. # # Conservative by Default # Only explicitly listed patterns are removed. The script never guesses # at file intent — if a pattern is not in the list, the file is untouched. # # ============================================================================================== # OPERATIONAL SAFEGUARDS # ============================================================================================== # # Root Enforcement # Media files are owned by container users; deleting them requires root. # # Profile Required # Exits with usage if no profile is given. There is no default profile — an # unspecified profile must never fall through to cleaning something. # # Lock Acquisition # acquire_lock "wait" — waits for a previous run to finish rather than # skipping, so a long anime pass does not cause the media pass to be dropped. # # Host Detection # detect_hosts() aliases HOST*_ANIME_CLEAN_FOLDERS / HOST*_MEDIA_CLEAN_FOLDERS # to the correct host's values. # # Empty Array Guards # Exits cleanly if the resolved folder list or pattern list is empty. An empty # pattern list would otherwise build a find with no -iname terms and match # every file in the tree. # # Clean Path Depth Guard # Every folder must be an absolute path at least three levels deep before it is # scanned. The patterns include *.sh, *.zip, *.rar and *.exe, so a truncated # entry like /mnt/user — which passes an existence check — would delete # matching files across every share on the array. # # Folder Existence # Missing folders are skipped with a warning; remaining folders still process. # # Explicit Pattern List # Only patterns named in ANIME_FILE_PATTERNS / MEDIA_FILE_PATTERNS are removed. # The script never infers intent from file size, age, or location. # # Count Before Delete # Matching files are counted first; a folder with zero matches short-circuits # before any rm is constructed. # # Dry Run Support # --dry-run lists every file that would be deleted and removes nothing. # # ============================================================================================== # CONFIGURATION # ============================================================================================== # # host*.conf # # HOST*_ANIME_CLEAN_FOLDERS — folders cleaned by the anime profile on this host # HOST*_MEDIA_CLEAN_FOLDERS — folders cleaned by the media profile on this host # Aliased by detect_hosts() — script uses ANIME_CLEAN_FOLDERS / MEDIA_CLEAN_FOLDERS # # master.conf # # ANIME_FILE_PATTERNS — file patterns removed by the anime profile # MEDIA_FILE_PATTERNS — file patterns removed by the media profile # # ============================================================================================== # RUNTIME MODES # ============================================================================================== # # media_cleaner.sh anime — clean anime shares # media_cleaner.sh media — clean media shares # media_cleaner.sh anime --dry-run — preview anime clean, no deletions # media_cleaner.sh media --dry-run — preview media clean, no deletions # media_cleaner.sh anime --log — verbose output # media_cleaner.sh anime --status — show config and exit # # ============================================================================================== SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../load_config.sh" # ── Separate profile argument from flags ────────────────────────────────────────────────────── # Profile (anime|media) is a positional arg — separate before parse_args sees flags PROFILE="" RAW_ARGS=() for ARG in "$@"; do case "$ARG" in anime|media) PROFILE="$ARG" ;; *) RAW_ARGS+=("$ARG") ;; esac done parse_args "${RAW_ARGS[@]}" # ============================================================================================== # ━━━ Setup ━━━ # ============================================================================================== if [[ "$EUID" -ne 0 ]]; then error "Must be run as root" exit 1 fi if [[ -z "$PROFILE" ]]; then error "No profile specified" error "Usage: media_cleaner.sh [--dry-run] [--log] [--status]" exit 1 fi acquire_lock "wait" # detect_hosts() sets MY_ID and aliases HOST*_ANIME/MEDIA_CLEAN_FOLDERS detect_hosts # Resolve profile folders and patterns case "$PROFILE" in anime) CLEAN_FOLDERS=("${ANIME_CLEAN_FOLDERS[@]}") FILE_PATTERNS=("${ANIME_FILE_PATTERNS[@]}") ;; media) CLEAN_FOLDERS=("${MEDIA_CLEAN_FOLDERS[@]}") FILE_PATTERNS=("${MEDIA_FILE_PATTERNS[@]}") ;; *) error "Unknown profile: $PROFILE — must be anime or media" exit 1 ;; esac # Empty array guards if [[ ${#CLEAN_FOLDERS[@]} -eq 0 ]]; then warn "No folders configured for profile '$PROFILE' on $MY_ID" warn "Check HOST*_${PROFILE^^}_CLEAN_FOLDERS in host*.conf" exit 0 fi if [[ ${#FILE_PATTERNS[@]} -eq 0 ]]; then warn "No file patterns configured for profile '$PROFILE'" warn "Check ${PROFILE^^}_FILE_PATTERNS in master.conf" exit 0 fi log "Profile: $PROFILE" log "Folders: ${#CLEAN_FOLDERS[@]}" log "Patterns: ${#FILE_PATTERNS[@]}" # ============================================================================================== # ━━━ Status ━━━ # ============================================================================================== if [[ "$SHOW_STATUS" == true ]]; then echo "" echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_GEAR Profile: $PROFILE" echo "$ICON_CLEAN Folders:" for f in "${CLEAN_FOLDERS[@]}"; do echo " $f" done echo "$ICON_TRASH Patterns: ${FILE_PATTERNS[*]}" echo "$ICON_GEAR Dry Run: $DRY_RUN" echo "━━━━━━━━━━━━━━━━━━━━━━━" exit 0 fi [[ "$DRY_RUN" == true ]] && warn "DRY RUN — no files will be deleted" # ============================================================================================== # ━━━ Media Cleaner ━━━ # ============================================================================================== echo "" echo "━━━ $ICON_CLEAN Media Cleaner — $PROFILE — $MY_ID ━━━" echo "" START=$(date +%s) TOTAL_REMOVED=0 FAILED=() SKIPPED=() for FOLDER in "${CLEAN_FOLDERS[@]}"; do FOLDER_NAME=$(basename "$FOLDER") echo "━━━ $ICON_CLEAN $FOLDER_NAME ━━━" # The pattern list includes *.sh, *.zip, *.rar and *.exe. A truncated entry such as # /mnt/user passes the -d check below and would sweep every share on the array, so # require an absolute path at least three levels deep before scanning anything. _depth="${FOLDER//[^\/]/}" if [[ -z "$FOLDER" || "$FOLDER" != /* || "${#_depth}" -lt 3 ]]; then error "Refusing to clean unsafe path: '${FOLDER:-empty}' — expected an absolute path at least 3 levels deep" notify "Media cleaner ($PROFILE) refused unsafe path on $(hostname): '${FOLDER:-empty}'" \ "Media Cleaner" "warning" FAILED+=("${FOLDER_NAME:-empty}") echo "" continue fi if [[ ! -d "$FOLDER" ]]; then warn "$FOLDER_NAME not found — skipping" SKIPPED+=("$FOLDER_NAME") echo "" continue fi # Build find command dynamically from FILE_PATTERNS array CMD=(find "$FOLDER" -type f \() for (( i = 0; i < ${#FILE_PATTERNS[@]}; i++ )); do CMD+=(-iname "${FILE_PATTERNS[i]}") if [[ $i -lt $(( ${#FILE_PATTERNS[@]} - 1 )) ]]; then CMD+=(-o) fi done CMD+=(\)) # Count matching files before acting FILE_COUNT=$("${CMD[@]}" 2>/dev/null | wc -l) if [[ "$FILE_COUNT" -eq 0 ]]; then echo "$FOLDER_NAME — clean ✅" echo "" continue fi warn "$ICON_TRASH $FILE_COUNT file(s) to remove from $FOLDER_NAME" if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — files that would be deleted:" "${CMD[@]}" 2>/dev/null | while IFS= read -r f; do echo " $ICON_TRASH $f" done else CLEAN_CMD=("${CMD[@]}" -exec rm -f {} +) if "${CLEAN_CMD[@]}" 2>/dev/null; then echo "$FOLDER_NAME — $FILE_COUNT file(s) removed" TOTAL_REMOVED=$(( TOTAL_REMOVED + FILE_COUNT )) else error "$FOLDER_NAME — cleanup failed" FAILED+=("$FOLDER_NAME") fi fi echo "" done END=$(date +%s) # ============================================================================================== # ━━━ Summary ━━━ # ============================================================================================== echo "━━━━━ $ICON_SUMMARY MEDIA CLEANER SUMMARY ━━━━━" echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)" echo "$ICON_GEAR Profile: $PROFILE" echo "$ICON_TIME Duration: $(format_duration $(( END - START )))" [[ ${#SKIPPED[@]} -gt 0 ]] && warn "Skipped: ${SKIPPED[*]} (folders not found)" [[ ${#FAILED[@]} -gt 0 ]] && echo "$ICON_ERROR Failed: ${FAILED[*]}" if [[ "$DRY_RUN" == true ]]; then warn "DRY RUN — no files deleted" elif [[ ${#FAILED[@]} -gt 0 ]]; then echo "$ICON_ERROR Status: SOME FOLDERS FAILED" notify "Media cleaner ($PROFILE) failed on $(hostname) — ${FAILED[*]}" \ "Media Cleaner" "warning" elif [[ "$TOTAL_REMOVED" -eq 0 ]]; then echo "$ICON_DONE Status: clean — nothing to remove" else warn "$ICON_TRASH Removed: $TOTAL_REMOVED file(s)" notify "Media cleaner ($PROFILE) on $(hostname) — $TOTAL_REMOVED file(s) removed" \ "Media Cleaner" "warning" fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" [[ ${#FAILED[@]} -gt 0 ]] && exit 1 exit 0