Files
shell_auto_mkvmerge/bin/auto_add_sub_folder.sh
2026-02-08 14:34:23 +01:00

714 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
# --- never exit silently (good for .desktop launch) ---
on_error() {
local ec=$?
echo
echo -e "${WHITE_ON_RED} FEHLER ${NC} Script abgebrochen (Exit Code: $ec)"
echo -e "${WHITE_ON_GRAY} Letzter Befehl ${NC} ${BASH_COMMAND}"
echo -e "${WHITE_ON_GRAY} Ort ${NC} ${BASH_SOURCE[1]}:${BASH_LINENO[0]}"
echo
read -n 1 -s -r -p "Press any key to exit"
exit "$ec"
}
trap on_error ERR
shopt -s nullglob
############################################
# auto_add_sub_folder.sh
#
# Reads input_folder from ../settings.ini (relative to this script in bin/)
# - If CLI args are provided, they override input_folder.
#
# Behavior:
# - Recursively finds *.mkv under root(s) (case-insensitive)
# - Robust sample exclusion (case-insensitive):
# * filename is "sample.mkv"
# * OR filename contains token "sample" (basename-sample.mkv, basename.Sample.mkv, basename_sample.mkv, etc.)
# * OR ANY directory component equals "sample" (sample/, SAMPLE/, SaMPle/)
#
# - For each MKV: finds a sibling subtitle folder (case-insensitive) via candidates list:
# Subs, Sub, Subtitles, Subtitle
# then muxes ALL .idx/.sub pairs found inside.
#
# - Language naming strategy:
# 1) Prefer filename tokens (more reliable for scene releases)
# 2) If filename yields no hint -> read .idx "id: xx" and map to German display names
# 3) If still unknown -> do not set --track-name
#
# - Subtitle ordering is configurable via settings.ini [subtitle_order] (read ONCE):
# order=de:forced,de,en:forced,en
# unknown=end
# unknown_forced_first=1
#
# mkvmerge:
# - auto-detect native OR flatpak (org.bunkus.mkvtoolnix-gui etc.)
#
# Safety:
# - Writes output to temp file then replaces original
# - Keeps .bak by default (disable via --no-bak)
############################################
# ───────────────────────── colors ─────────────────────────
RED='\033[0;31m'
GREEN='\033[0;92m'
BLUE='\033[0;94m'
WHITE_ON_GRAY='\033[0;37;100m'
BLACK_ON_WHITE='\033[0;30;47m'
WHITE_ON_RED='\033[0;37;41m'
NC='\033[0m' # No Color
DRY_RUN=0
KEEP_BAK=1
VERBOSE=1
# Robust subtitle folder detection (case-insensitive)
SUBDIR_CANDIDATES=("Subs" "Sub" "Subtitles" "Subtitle")
# Defaults for subtitle order (can be overridden in settings.ini)
SUB_ORDER_RAW_DEFAULT="de:forced,de,en:forced,en"
SUB_UNKNOWN_MODE_DEFAULT="end" # end | keep
SUB_UNKNOWN_FORCED_FIRST_DEFAULT="1" # 1/0
# Will be filled in main() once
SUB_ORDER_RAW=""
SUB_UNKNOWN_MODE=""
SUB_UNKNOWN_FORCED_FIRST=""
# mkvmerge command (native or flatpak)
MKVMERGE_CMD=()
# Order rank map built once from SUB_ORDER_RAW
declare -A SUB_ORDER_RANK=()
# ---------------- language "database" for display names (GERMAN) ----------------
# Maps ISO 639-1/2 codes to German display names
declare -A LANG_DB=(
[de]="Deutsch" [deu]="Deutsch" [ger]="Deutsch"
[en]="Englisch" [eng]="Englisch"
[fr]="Französisch" [fra]="Französisch" [fre]="Französisch"
[es]="Spanisch" [spa]="Spanisch"
[it]="Italienisch" [ita]="Italienisch"
[nl]="Niederländisch" [nld]="Niederländisch" [dut]="Niederländisch"
[pl]="Polnisch" [pol]="Polnisch"
[ru]="Russisch" [rus]="Russisch"
[pt]="Portugiesisch" [por]="Portugiesisch"
[tr]="Türkisch" [tur]="Türkisch"
[sv]="Schwedisch" [swe]="Schwedisch"
[no]="Norwegisch" [nor]="Norwegisch"
[da]="Dänisch" [dan]="Dänisch"
[fi]="Finnisch" [fin]="Finnisch"
[cs]="Tschechisch" [ces]="Tschechisch" [cze]="Tschechisch"
[hu]="Ungarisch" [hun]="Ungarisch"
[ro]="Rumänisch" [ron]="Rumänisch" [rum]="Rumänisch"
[bg]="Bulgarisch" [bul]="Bulgarisch"
[el]="Griechisch" [ell]="Griechisch" [gre]="Griechisch"
[he]="Hebräisch" [heb]="Hebräisch"
)
usage() {
cat <<'EOF'
Usage:
auto_add_sub_folder.sh [options] [root] [root2 ...]
If no root is given:
- reads [pathes] input_folder from ../settings.ini
Options:
-n, --dry-run Print commands only, do not execute
--no-bak Do not keep .bak of original MKV
-q, --quiet Less logging
-h, --help Show this help
EOF
}
log() { (( VERBOSE == 1 )) && printf '[%s] %s\n' "$(date +'%F %T')" "$*"; }
dbg() { (( VERBOSE == 1 )) && printf ' %s\n' "$*"; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
# ---------------- INI parsing (minimal, safe for values with spaces) ----------------
ini_get() {
local ini_file="$1"
local section="$2"
local key="$3"
awk -v sec="[$section]" -v key="$key" '
function ltrim(s){ sub(/^[ \t\r\n]+/, "", s); return s }
function rtrim(s){ sub(/[ \t\r\n]+$/, "", s); return s }
function trim(s){ return rtrim(ltrim(s)) }
BEGIN{ insec=0 }
{
line=$0
sub(/^\xef\xbb\xbf/, "", line) # strip BOM if present
if (line ~ /^[ \t]*#/) next
if (line ~ /^[ \t]*;/) next
if (match(line, /^[ \t]*\[[^]]+\][ \t]*$/)) {
insec = (trim(line) == sec)
next
}
if (!insec) next
if (match(line, /^[ \t]*[^=]+=/)) {
split(line, a, "=")
k = trim(a[1])
if (k != key) next
pos = index(line, "=")
v = substr(line, pos+1)
v = trim(v)
if (v ~ /^".*"$/) { sub(/^"/, "", v); sub(/"$/, "", v) }
print v
exit
}
}
' "$ini_file"
}
# ---------------- mkvmerge detection ----------------
detect_mkvmerge() {
log "Detecting mkvmerge…"
if command -v mkvmerge >/dev/null 2>&1; then
MKVMERGE_CMD=(mkvmerge)
log "Using native mkvmerge: $(command -v mkvmerge)"
return 0
fi
if command -v flatpak >/dev/null 2>&1; then
local candidates=(
"org.bunkus.mkvtoolnix-gui"
"org.bunkus.mkvtoolnix.MKVToolNix"
"org.bunkus.mkvtoolnix"
)
local appid="" c=""
for c in "${candidates[@]}"; do
if flatpak info "$c" >/dev/null 2>&1; then
appid="$c"
break
fi
done
if [[ -z "$appid" ]]; then
appid="$(flatpak list --app --columns=application 2>/dev/null | grep -i 'mkvtoolnix' | head -n 1 || true)"
fi
if [[ -n "$appid" ]]; then
MKVMERGE_CMD=(flatpak run --command=mkvmerge "$appid")
log "Using mkvmerge via Flatpak: $appid"
return 0
fi
fi
die "Neither native mkvmerge nor a MKVToolNix Flatpak installation was found."
}
# ---------------- sample exclusion ----------------
is_sample_mkv() {
local f="$1"
local base bn lc
base="$(basename "$f")"
bn="${base%.*}"
lc="${bn,,}"
if [[ "$lc" == "sample" ]]; then
return 0
fi
if [[ "$lc" =~ (^|[._\ \-])sample([._\ \-]|$) ]]; then
return 0
fi
local d seg
d="$(dirname "$f")"
while [[ "$d" != "/" && -n "$d" ]]; do
seg="$(basename "$d")"
if [[ "${seg,,}" == "sample" ]]; then
return 0
fi
d="$(dirname "$d")"
done
return 1
}
# ---------------- subtitle folder finder ----------------
find_subs_dir() {
local mkv="$1"
local dir cand hit
dir="$(dirname "$mkv")"
for cand in "${SUBDIR_CANDIDATES[@]}"; do
hit="$(find "$dir" -maxdepth 1 -mindepth 1 -type d -iname "$cand" -print -quit 2>/dev/null || true)"
if [[ -n "$hit" ]]; then
echo "$hit"
return 0
fi
done
return 1
}
# ---------------- language helpers ----------------
lang_name_from_code() {
local code="${1,,}"
[[ -n "${LANG_DB[$code]:-}" ]] && printf '%s' "${LANG_DB[$code]}"
}
# Extract language code from VobSub .idx (first "id:" line), lowercased.
idx_lang_from_file() {
local idx="$1"
local code=""
code="$(awk '
BEGIN{IGNORECASE=1}
/^[ \t]*id:[ \t]*[a-z][a-z][a-z]?[ \t]*,/ {
gsub(/^[ \t]*/, "", $0)
sub(/^id:[ \t]*/, "", $0)
sub(/,.*/, "", $0)
print tolower($0)
exit
}
/^[ \t]*id:[ \t]*[a-z][a-z][a-z]?[ \t]*$/ {
gsub(/^[ \t]*/, "", $0)
sub(/^id:[ \t]*/, "", $0)
print tolower($0)
exit
}
' "$idx" 2>/dev/null || true)"
[[ -n "$code" ]] && printf '%s' "$code"
}
# Infer a language code from filename tokens (best effort).
# Returns: code or empty (exit 1)
lang_code_from_filename() {
local stem="$1"
local lc="${stem,,}"
# English
if [[ "$lc" =~ (^|[._\ \-])eng([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])en([._\ \-]|$) ]] || \
[[ "$lc" == *english* ]]; then
printf 'en'; return 0
fi
# German
if [[ "$lc" =~ (^|[._\ \-])deu([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])ger([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])de([._\ \-]|$) ]] || \
[[ "$lc" == *german* ]]; then
printf 'de'; return 0
fi
# French
if [[ "$lc" =~ (^|[._\ \-])fra([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])fre([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])fr([._\ \-]|$) ]] || \
[[ "$lc" == *french* ]]; then
printf 'fr'; return 0
fi
# Spanish
if [[ "$lc" =~ (^|[._\ \-])spa([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])es([._\ \-]|$) ]] || \
[[ "$lc" == *spanish* ]]; then
printf 'es'; return 0
fi
# Italian
if [[ "$lc" =~ (^|[._\ \-])ita([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])it([._\ \-]|$) ]] || \
[[ "$lc" == *italian* ]]; then
printf 'it'; return 0
fi
# Dutch
if [[ "$lc" =~ (^|[._\ \-])nld([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])dut([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])nl([._\ \-]|$) ]] || \
[[ "$lc" == *dutch* ]]; then
printf 'nl'; return 0
fi
# Polish
if [[ "$lc" =~ (^|[._\ \-])pol([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])pl([._\ \-]|$) ]] || \
[[ "$lc" == *polish* ]]; then
printf 'pl'; return 0
fi
# Russian
if [[ "$lc" =~ (^|[._\ \-])rus([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])ru([._\ \-]|$) ]] || \
[[ "$lc" == *russian* ]]; then
printf 'ru'; return 0
fi
# Portuguese
if [[ "$lc" =~ (^|[._\ \-])por([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])pt([._\ \-]|$) ]] || \
[[ "$lc" == *portuguese* ]]; then
printf 'pt'; return 0
fi
# Turkish
if [[ "$lc" =~ (^|[._\ \-])tur([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])tr([._\ \-]|$) ]] || \
[[ "$lc" == *turkish* ]]; then
printf 'tr'; return 0
fi
# Hebrew
if [[ "$lc" =~ (^|[._\ \-])heb([._\ \-]|$) ]] || \
[[ "$lc" =~ (^|[._\ \-])he([._\ \-]|$) ]] || \
[[ "$lc" == *hebrew* ]]; then
printf 'he'; return 0
fi
return 1
}
# Infer language code for sorting:
# 1) filename tokens, else 2) idx id:xx, else "und"
infer_lang_code_for_idx() {
local idx="$1"
local stem code
stem="$(basename "$idx" .idx)"
code="$(lang_code_from_filename "$stem" || true)"
[[ -n "${code:-}" ]] && { printf '%s' "$code"; return 0; }
code="$(idx_lang_from_file "$idx" || true)"
[[ -n "${code:-}" ]] && { printf '%s' "$code"; return 0; }
printf 'und'
}
# Track naming (German display names), prefers filename tokens, falls back to idx content.
# Echoes: track_name|forcedFlag
infer_meta_for_idx() {
local idx="$1"
local stem lc forced name code_file code_idx
stem="$(basename "$idx" .idx)"
lc="${stem,,}"
forced="no"
[[ "$lc" == *forced* ]] && forced="yes"
name=""
code_file="$(lang_code_from_filename "$stem" || true)"
if [[ -n "${code_file:-}" ]]; then
name="$(lang_name_from_code "$code_file" || true)"
fi
if [[ -z "$name" ]]; then
code_idx="$(idx_lang_from_file "$idx" || true)"
if [[ -n "${code_idx:-}" ]]; then
name="$(lang_name_from_code "$code_idx" || true)"
fi
fi
# warn on mismatch if both exist (filename wins)
if [[ -n "${code_file:-}" ]]; then
code_idx="$(idx_lang_from_file "$idx" || true)"
if [[ -n "${code_idx:-}" && "${code_idx,,}" != "${code_file,,}" ]]; then
dbg "WARN: Sprach-Mismatch für $(basename "$idx"): Dateiname=${code_file}, IDX=${code_idx} (Dateiname gewinnt)"
fi
fi
[[ -n "$name" && "$forced" == "yes" ]] && name+=" (Forced)"
echo "${name}|${forced}"
}
# ---------------- subtitle order helpers ----------------
build_sub_order_rank() {
local raw="$1"
SUB_ORDER_RANK=() # reset
local i=0 item
IFS=',' read -r -a _items <<<"$raw"
for item in "${_items[@]}"; do
item="${item//[[:space:]]/}"
[[ -z "$item" ]] && continue
SUB_ORDER_RANK["$item"]=$i
((i+=1))
done
}
subtitle_sort_rank() {
local lang="$1" forced="$2"
local ftok="normal"
[[ "$forced" == "yes" ]] && ftok="forced"
# exact match: de:forced
if [[ -n "${SUB_ORDER_RANK["$lang:$ftok"]+x}" ]]; then
printf '%05d' "${SUB_ORDER_RANK["$lang:$ftok"]}"
return 0
fi
# fallback: de
if [[ -n "${SUB_ORDER_RANK["$lang"]+x}" ]]; then
printf '%05d' "${SUB_ORDER_RANK["$lang"]}"
return 0
fi
# unknown handling
if [[ "${SUB_UNKNOWN_MODE:-end}" == "end" ]]; then
# unknowns go behind knowns; optionally forced unknown before non-forced unknown
local base=90000 bump=0
if [[ "${SUB_UNKNOWN_FORCED_FIRST:-1}" == "1" ]]; then
[[ "$forced" == "yes" ]] && bump=0 || bump=1
fi
printf '%05d' "$((base + bump))"
return 0
fi
# keep
printf '%05d' 90000
}
# ---------------- mux one mkv ----------------
mux_one_mkv() {
local mkv="$1"
local subs
log "MKV found: $mkv"
subs="$(find_subs_dir "$mkv" || true)"
dbg "Subs dir : ${subs:-<not found>}"
if [[ -z "${subs:-}" ]]; then
dbg "No subtitles folder found (tried: ${SUBDIR_CANDIDATES[*]}) -> skip"
return 0
fi
local -a idxs=()
local f=""
while IFS= read -r -d '' f; do
idxs+=("$f")
done < <(find "$subs" -maxdepth 1 -type f -iname "*.idx" -print0)
if [[ ${#idxs[@]} -eq 0 ]]; then
dbg "Subtitle folder exists but no .idx files -> skip"
return 0
fi
# ---- sort idxs according to settings-defined order ----
local -a decorated=()
local idx forcedFlag langcode rank
for idx in "${idxs[@]}"; do
forcedFlag="no"
[[ "${idx,,}" == *forced* ]] && forcedFlag="yes"
langcode="$(infer_lang_code_for_idx "$idx")"
rank="$(subtitle_sort_rank "$langcode" "$forcedFlag")"
# Decorate line for sorting:
# rank<TAB>lang<TAB>forced<TAB>path
decorated+=( "${rank}"$'\t'"${langcode}"$'\t'"${forcedFlag}"$'\t'"${idx}" )
done
IFS=$'\n' decorated=($(printf '%s\n' "${decorated[@]}" | LC_ALL=C sort))
unset IFS
idxs=()
local row
for row in "${decorated[@]}"; do
idxs+=( "$(printf '%s' "$row" | cut -f4-)" )
done
dbg "IDX files (sorted): ${#idxs[@]}"
for f in "${idxs[@]}"; do dbg " - $(basename "$f")"; done
local out_tmp="${mkv%.*}.with-subs.tmp.mkv"
local bak="${mkv}.bak"
local -a cmd=()
cmd=("${MKVMERGE_CMD[@]}" -o "$out_tmp" "$mkv")
local added=0
for idx in "${idxs[@]}"; do
local sub="${idx%.*}.sub"
if [[ ! -f "$sub" ]]; then
dbg "Skip idx (missing paired .sub): $(basename "$idx")"
continue
fi
local meta name forcedFlag2
meta="$(infer_meta_for_idx "$idx")"
IFS='|' read -r name forcedFlag2 <<<"$meta"
dbg "Add VobSub: $(basename "$idx") -> name='${name:-<none>}', forced=$forcedFlag2"
# Do NOT override language; keep what is in the .idx
if [[ -n "$name" ]]; then
cmd+=( --track-name 0:"$name" )
fi
if [[ "$forcedFlag2" == "yes" ]]; then
cmd+=( --forced-track 0:yes --default-track 0:no )
else
cmd+=( --forced-track 0:no --default-track 0:no )
fi
cmd+=( "$idx" )
((++added))
done
if [[ $added -eq 0 ]]; then
dbg "No usable subtitle sets (.idx + .sub) -> nothing to mux"
return 0
fi
log "Muxing $added subtitle input(s) into: $(basename "$mkv")"
dbg "Temp out : $out_tmp"
dbg "Command : ${cmd[*]}"
if [[ $DRY_RUN -eq 1 ]]; then
log "Dry-run: not executing."
return 0
fi
"${cmd[@]}"
[[ -f "$out_tmp" ]] || die "mkvmerge finished but temp output file not found: $out_tmp"
if [[ $KEEP_BAK -eq 1 ]]; then
mv -f -- "$mkv" "$bak"
mv -f -- "$out_tmp" "$mkv"
log "Done. Backup kept: $(basename "$bak")"
else
mv -f -- "$out_tmp" "$mkv"
log "Done. Original replaced (no .bak)."
fi
}
# ---------------- scan root ----------------
process_root() {
local root="$1"
[[ -e "$root" ]] || { log "Root not found: $root"; return 0; }
log "Scanning root: $root"
local -a mkvs=()
local f=""
while IFS= read -r -d '' f; do
if is_sample_mkv "$f"; then
dbg "Skip MKV (sample rule): $f"
continue
fi
mkvs+=("$f")
done < <(find "$root" -type f -iname "*.mkv" -print0)
log "Found MKVs: ${#mkvs[@]} under $root"
[[ ${#mkvs[@]} -eq 0 ]] && return 0
# ---- sort MKVs alphabetically (stable episode order) ----
IFS=$'\n' mkvs=($(printf '%s\n' "${mkvs[@]}" | LC_ALL=C sort))
unset IFS
dbg "MKV Reihenfolge:"
for f in "${mkvs[@]}"; do dbg " - $(basename "$f")"; done
local processed=0 mkv=""
for mkv in "${mkvs[@]}"; do
mux_one_mkv "$mkv"
((processed+=1))
done
log "Finished root: $root (processed $processed MKV(s))"
}
# ---------------- main ----------------
main() {
local -a ROOTS=()
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--dry-run) DRY_RUN=1; shift ;;
--no-bak) KEEP_BAK=0; shift ;;
-q|--quiet) VERBOSE=0; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) die "Unknown option: $1" ;;
*) ROOTS+=("$1"); shift ;;
esac
done
local script_dir base_dir ini_file
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
base_dir="$(cd "$script_dir/.." && pwd)"
ini_file="$base_dir/settings.ini"
# settings.ini only needed if no CLI roots
if [[ ${#ROOTS[@]} -eq 0 ]]; then
if [[ ! -f "$ini_file" ]]; then
echo
echo -e "${WHITE_ON_RED} Settingsfile nicht gefunden: $ini_file ${NC}"
echo
read -n 1 -s -r -p "Press any key to exit"
exit 1
fi
local input_folder
input_folder="$(ini_get "$ini_file" "pathes" "input_folder" || true)"
if [[ -z "${input_folder:-}" ]]; then
echo
echo -e "${WHITE_ON_RED} input_folder fehlt in settings.ini ([pathes]) ${NC}"
echo
read -n 1 -s -r -p "Press any key to exit"
exit 1
fi
ROOTS=("$input_folder")
fi
# Read subtitle order ONCE (only if ini exists; if script is run with CLI roots and no ini, use defaults)
if [[ -f "$ini_file" ]]; then
SUB_ORDER_RAW="$(ini_get "$ini_file" "subtitle_order" "order" || true)"
SUB_UNKNOWN_MODE="$(ini_get "$ini_file" "subtitle_order" "unknown" || true)"
SUB_UNKNOWN_FORCED_FIRST="$(ini_get "$ini_file" "subtitle_order" "unknown_forced_first" || true)"
fi
SUB_ORDER_RAW="${SUB_ORDER_RAW:-$SUB_ORDER_RAW_DEFAULT}"
SUB_UNKNOWN_MODE="${SUB_UNKNOWN_MODE:-$SUB_UNKNOWN_MODE_DEFAULT}"
SUB_UNKNOWN_FORCED_FIRST="${SUB_UNKNOWN_FORCED_FIRST:-$SUB_UNKNOWN_FORCED_FIRST_DEFAULT}"
build_sub_order_rank "$SUB_ORDER_RAW"
detect_mkvmerge
echo
echo "────────────────────────────────────────────────────────────────"
echo -e "${BLACK_ON_WHITE} Auto Add Sub Folder (VobSub) ${NC}"
echo "────────────────────────────────────────────────────────────────"
echo " "
echo -e "${WHITE_ON_GRAY} Input Folder ${NC} ${ROOTS[*]}"
echo -e "${WHITE_ON_GRAY} Subs Folders ${NC} ${SUBDIR_CANDIDATES[*]}"
echo -e "${WHITE_ON_GRAY} Reihenfolge ${NC} ${SUB_ORDER_RAW}"
echo -e "${WHITE_ON_GRAY} MKVMerge ${NC} ${MKVMERGE_CMD[*]}"
echo -e "${WHITE_ON_GRAY} Dry Run ${NC} ${DRY_RUN}"
echo -e "${WHITE_ON_GRAY} Keep Backup ${NC} ${KEEP_BAK}"
echo " "
echo "────────────────────────────────────────────────────────────────"
echo
local r=""
for r in "${ROOTS[@]}"; do
process_root "$r"
done
echo
echo "────────────────────────────────────────────────────────────────"
echo -e "${GREEN}✔ Finished${NC}"
echo
read -n 1 -s -r -p "Press any key to exit"
exit 0
}
main "$@"