#!/usr/bin/env bash set -Eeuo pipefail 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. # # - Does NOT override language (VobSub language usually lives in the .idx) # - Sets track-name (heuristic) + forced flag based on idx filename only # # 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") MKVMERGE_CMD=() 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) ---------------- # Reads first matching key in a section: # [section] # key=value 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 # strip BOM if present sub(/^\xef\xbb\xbf/, "", line) # ignore comments (full-line) if (line ~ /^[ \t]*#/) next if (line ~ /^[ \t]*;/) next # section header if (match(line, /^[ \t]*\[[^]]+\][ \t]*$/)) { insec = (trim(line) == sec) next } if (!insec) next # key=value, keep everything after first '=' (may contain spaces) 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) # remove optional surrounding quotes 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 ---------------- # Returns 0 if path should be excluded as sample; otherwise 1. is_sample_mkv() { local f="$1" local base bn lc base="$(basename "$f")" bn="${base%.*}" lc="${bn,,}" # 1) exactly "sample" if [[ "$lc" == "sample" ]]; then return 0 fi # 2) token "sample" in basename (separators: dot, underscore, hyphen, space) if [[ "$lc" =~ (^|[._\ \-])sample([._\ \-]|$) ]]; then return 0 fi # 3) any directory component equals "sample" (case-insensitive) 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 (case-insensitive candidates) ---------------- 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 "database" for display names (GERMAN) ---------------- # Maps ISO 639-1/2 codes to German display names declare -A LANG_DB=( # Deutsch [de]="Deutsch" [deu]="Deutsch" [ger]="Deutsch" # Englisch [en]="Englisch" [eng]="Englisch" # Französisch [fr]="Französisch" [fra]="Französisch" [fre]="Französisch" # Spanisch [es]="Spanisch" [spa]="Spanisch" # Italienisch [it]="Italienisch" [ita]="Italienisch" # Niederländisch [nl]="Niederländisch" [nld]="Niederländisch" [dut]="Niederländisch" # Polnisch [pl]="Polnisch" [pol]="Polnisch" # Russisch [ru]="Russisch" [rus]="Russisch" # Portugiesisch [pt]="Portugiesisch" [por]="Portugiesisch" # Türkisch [tr]="Türkisch" [tur]="Türkisch" # Schwedisch [sv]="Schwedisch" [swe]="Schwedisch" # Norwegisch [no]="Norwegisch" [nor]="Norwegisch" # Dänisch [da]="Dänisch" [dan]="Dänisch" # Finnisch [fi]="Finnisch" [fin]="Finnisch" # Tschechisch [cs]="Tschechisch" [ces]="Tschechisch" [cze]="Tschechisch" # Ungarisch [hu]="Ungarisch" [hun]="Ungarisch" # Rumänisch [ro]="Rumänisch" [ron]="Rumänisch" [rum]="Rumänisch" # Bulgarisch [bg]="Bulgarisch" [bul]="Bulgarisch" # Griechisch [el]="Griechisch" [ell]="Griechisch" [gre]="Griechisch" # Hebräisch [he]="Hebräisch" [heb]="Hebräisch" ) # Extract language code from VobSub .idx (first "id:" line), lowercased. # Example line: "id: en, index: 0" 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" } # Try to infer a language "code" from filename tokens (best effort). # Returns: code or empty lang_code_from_filename() { local stem="$1" local lc="${stem,,}" # English tokens if [[ "$lc" =~ (^|[._\ \-])eng([._\ \-]|$) ]] || \ [[ "$lc" =~ (^|[._\ \-])en([._\ \-]|$) ]] || \ [[ "$lc" == *english* ]]; then printf 'en'; return 0 fi # German tokens if [[ "$lc" =~ (^|[._\ \-])deu([._\ \-]|$) ]] || \ [[ "$lc" =~ (^|[._\ \-])ger([._\ \-]|$) ]] || \ [[ "$lc" =~ (^|[._\ \-])de([._\ \-]|$) ]] || \ [[ "$lc" == *german* ]]; then printf 'de'; return 0 fi # French tokens if [[ "$lc" =~ (^|[._\ \-])fra([._\ \-]|$) ]] || \ [[ "$lc" =~ (^|[._\ \-])fre([._\ \-]|$) ]] || \ [[ "$lc" =~ (^|[._\ \-])fr([._\ \-]|$) ]] || \ [[ "$lc" == *french* ]]; then printf 'fr'; return 0 fi # Spanish tokens if [[ "$lc" =~ (^|[._\ \-])spa([._\ \-]|$) ]] || \ [[ "$lc" =~ (^|[._\ \-])es([._\ \-]|$) ]] || \ [[ "$lc" == *spanish* ]]; then printf 'es'; return 0 fi # Italian tokens if [[ "$lc" =~ (^|[._\ \-])ita([._\ \-]|$) ]] || \ [[ "$lc" =~ (^|[._\ \-])it([._\ \-]|$) ]] || \ [[ "$lc" == *italian* ]]; then printf 'it'; return 0 fi # Add more here if you want... return 1 } # Convert a language code to a nice name using LANG_DB. # Returns: name or empty lang_name_from_code() { local code="${1,,}" [[ -n "${LANG_DB[$code]:-}" ]] && printf '%s' "${LANG_DB[$code]}" } # Echoes: track_name|forcedFlag # Strategy: # 1) Prefer filename tokens (more reliable for scene releases) # 2) If filename gives no hint -> read idx "id: xx" and map to display name # 3) If still unknown -> empty name (=> don't set --track-name) 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="" # 1) from filename code_file="$(lang_code_from_filename "$stem" || true)" if [[ -n "${code_file:-}" ]]; then name="$(lang_name_from_code "$code_file" || true)" fi # 2) fallback: from idx file content 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 # Optional: 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: language mismatch for $(basename "$idx"): filename=$code_file, idx=$code_idx (using filename)" fi fi [[ -n "$name" && "$forced" == "yes" ]] && name+=" (Forced)" echo "${name}|${forced}" } # ---------------- 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 dbg "IDX files: ${#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 local idx="" 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 forcedFlag meta="$(infer_meta_for_idx "$idx")" IFS='|' read -r name forcedFlag <<<"$meta" dbg "Add VobSub: $(basename "$idx") -> name='$name', forced=$forcedFlag" # IMPORTANT: do NOT override language; keep what is in the .idx if [[ -n "$name" ]]; then cmd+=( --track-name 0:"$name" ) fi if [[ "$forcedFlag" == "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" if [[ ${#mkvs[@]} -eq 0 ]]; then return 0 fi local processed=0 local mkv="" for mkv in "${mkvs[@]}"; do mux_one_mkv "$mkv" ((++processed)) done log "Finished root: $root (processed $processed MKV(s))" } # ---------------- main ---------------- main() { 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" # If no CLI roots: read input_folder from settings.ini 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 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} 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 "$@"