#!/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:-}" 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: # ranklangforcedpath 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:-}', 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 "$@"