#!/bin/bash
# dvd2mkv — encode the episodes on DVD rips to named, tagged MKV files.
#
# Episode titles and their order come from `dvdmap`; names come from a
# titles file (one episode title per line), from --name, or (for movies)
# from the source's own name. Shows a plan unless given -y.
# Run `dvd2mkv --help` for a summary and `man dvd2mkv` for the manual.
#
# SPDX-License-Identifier: CC0-1.0
set -euo pipefail

VERSION=0.4.0
read -r -a HB <<<"${DVD2MKV_HANDBRAKE:-flatpak run --command=HandBrakeCLI fr.handbrake.ghb}"
DEFAULT_PRESET=${DVD2MKV_PRESET:-Main Mkv}

usage() {
    cat <<EOF
Usage: dvd2mkv [OPTIONS] [TITLES_FILE] SOURCE...
       dvd2mkv --chapters SPEC [OPTIONS] [TITLES_FILE] SOURCE...
       dvd2mkv --movie [OPTIONS] SOURCE...

Encode the episodes on DVD rips to MKV, named "NN - Title.mkv" and tagged
with the episode title. Without -y it only prints the plan.

SOURCE is checked automatically and may be:
  a DVD mirror (dir holding VIDEO_TS/), a VIDEO_TS dir, or an .iso
  a folder of mirrors/ISOs (each used in name order), e.g. a season folder
  a .vob file, or a folder of .vob files (one episode per file)
  DISC:2,3,5   use exactly these titles from DISC instead of dvdmap's guess
Names come from TITLES_FILE (one per line) or --name; without either the
plan is shown with no names (it cannot encode), except with --movie.

Options:
  -y, --yes                encode (default: just show the plan)
  -o, --output DIR         where to put the MKV files (default: .)
  -p, --preset NAME        HandBrake preset (default: "$DEFAULT_PRESET")
  -a, --audio LIST         audio tracks to keep, for every episode
  -A, --audio-for EPS=LIST audio tracks for some episodes, e.g. 3,5-7=eng,2
                           (repeatable; overrides --audio for those episodes)
  -n, --name NAME          name the next episode (repeatable; instead of
                           a titles file)
  -m, --movie              one title per SOURCE, the longest; no "NN - "
                           prefix; without a name the file keeps the
                           source's name and the title tag is tidied from it
  -c, --chapters SPEC      episodes are chapters of each title: SPEC is
                           N (N chapters per episode) or ranges like
                           1-4,5-8,9-12 (applied to every selected title;
                           a single chapter on its own is written 5-5)
  -b, --brief              one line per episode in the plan (no track lines)
  -L, --list-presets       list HandBrake presets and the defaults, then exit
  -h, --help               show this help, then exit
  -V, --version            show the version, then exit

LIST is comma-separated, in the order you want the tracks: track numbers
as shown in the plan (2), languages (eng), "all" (every track not already
listed) or "none". Example: -a eng,all puts English first, keeps the rest.

See "man dvd2mkv" for details and examples.
EOF
}

die() { echo "dvd2mkv: $1" >&2; exit "${2:-1}"; }

# ---------- presets ----------

presets_file() {
    local f
    for f in "${DVD2MKV_PRESETS:-}" ~/.var/app/fr.handbrake.ghb/config/ghb/presets.json \
             "${XDG_CONFIG_HOME:-$HOME/.config}/ghb/presets.json"; do
        [[ -n $f && -r $f ]] && { echo "$f"; return; }
    done
    return 1
}

# the preset's JSON (custom presets win over built-ins of the same name)
preset_json() {
    local f
    f=$(presets_file) && command -v jq >/dev/null || return 1
    jq -ce --arg n "$1" '[.. | objects | select(.PresetName? == $n and (.Folder | not))]
                         | sort_by(-.Type) | first // empty' "$f"
}

# one-line summary of what a preset does with audio and subtitles
preset_summary() {
    jq -r '
      def langs($l): if ($l | length) == 0 or $l == ["any"] then "any language"
                     else $l | join(",") end;
      def pick($b; $l): if $b == "none" then "none"
                        elif $b == "first" then "first track (\(langs($l)))"
                        else "all tracks (\(langs($l)))" end;
      def enc: if (.AudioEncoder | startswith("copy"))
               then "passthru" + (.AudioEncoder | ltrimstr("copy") | sub(":"; " ") | ascii_upcase)
               else (.AudioEncoder | ltrimstr("av_") | ascii_upcase) + " " + (.AudioMixdown | sub("point"; "."))
                    + (if .AudioTrackQualityEnable then " q\(.AudioTrackQuality)"
                       elif .AudioBitrate then " \(.AudioBitrate)k" else "" end) end;
      "audio: \(pick(.AudioTrackSelectionBehavior; .AudioLanguageList // []))"
      + " -> \([.AudioList[] | enc] | join(" + "))"
      + "; subtitles: \(pick(.SubtitleTrackSelectionBehavior; .SubtitleLanguageList // []))"'
}

list_presets() {
    local f gui
    if ! f=$(presets_file) || ! command -v jq >/dev/null; then
        echo "dvd2mkv default preset: $DEFAULT_PRESET"
        echo "(no HandBrake GUI presets.json or no jq; asking HandBrakeCLI)"
        "${HB[@]}" --preset-import-gui -z </dev/null 2>&1 | grep -v '^\[' || true
        return
    fi
    gui=$(jq -r '[.. | objects | select(.Default? == true) | .PresetName] | first // "?"' "$f")
    printf 'dvd2mkv default preset:  %s   (set DVD2MKV_PRESET to change)\n' "$DEFAULT_PRESET"
    printf 'HandBrake GUI default:   %s\n\n' "$gui"
    echo "Your presets:"
    jq -c '.PresetList[] | select(.Type == 1)
           | if .Folder then {folder: .PresetName, p: .ChildrenArray[]} else {folder: "", p: .} end' "$f" |
    while IFS= read -r row; do
        local name folder marks=() desc
        name=$(jq -r .p.PresetName <<<"$row")
        folder=$(jq -r .folder <<<"$row")
        [[ $name == "$DEFAULT_PRESET" ]] && marks+=("dvd2mkv default")
        [[ $name == "$gui" ]] && marks+=("GUI default")
        printf '  %s%s' "${folder:+$folder/}" "$name"
        if ((${#marks[@]})); then
            local m; m=$(printf '%s, ' "${marks[@]}")
            printf '   [%s]' "${m%, }"
        fi
        echo
        desc=$(jq -r '.p.PresetDescription // empty' <<<"$row")
        [[ -n $desc ]] && echo "      $desc"
        echo "      $(jq -c .p <<<"$row" | preset_summary)"
    done
    echo
    echo "Built-in presets:"
    jq -r '.PresetList[] | select(.Type == 0 and .Folder)
           | .PresetName as $f | .ChildrenArray[] | "\($f)\t\(.PresetName)"' "$f" |
    awk -F'\t' '   # pack names into lines of at most 78 columns, per folder
        $1 != folder { if (line != "") print line; folder = $1; line = "  " $1 ":"; sep = " " }
        { if (length(line) + length(sep $2) > 78) { print line; line = "     "; sep = " " }
          line = line sep $2; sep = " | " }
        END { if (line != "") print line }'
}

# ---------- sources ----------

is_disc() {   # a DVD mirror, a VIDEO_TS dir, or an ISO image
    [[ -f $1 && ${1,,} == *.iso ]] ||
        [[ -d $1 && ( -f $1/VIDEO_TS/VIDEO_TS.IFO || -f $1/VIDEO_TS.IFO ) ]]
}

# print "disc PATH" / "vob PATH" lines for one SOURCE argument
classify() {
    local src=$1 kids=()
    if is_disc "$src"; then echo "disc $src"; return; fi
    if [[ -f $src && ${src,,} == *.vob ]]; then echo "vob $src"; return; fi
    [[ -f $src ]] && die "$src: not a DVD source (a titles file must come first)"
    [[ -d $src ]] || die "$src: no such file or directory"
    mapfile -t kids < <(find "$src" -mindepth 1 -maxdepth 1 \( -xtype d -o -iname '*.iso' \) | sort -V)
    local k found=0
    for k in "${kids[@]}"; do
        is_disc "$k" && { echo "disc $k"; found=1; }
    done
    ((found)) && return
    mapfile -t kids < <(find "$src" -maxdepth 1 -xtype f -iname '*.vob' | sort -V)
    ((${#kids[@]})) || die "$src: not a DVD mirror, ISO, VOB, or a folder of those"
    printf 'vob %s\n' "${kids[@]}"
}

declare -A D_EPS D_TITLES T_SECS T_CH T_VIDEO T_AUDIO T_SUBS T_CHSECS

load_disc() {   # run dvdmap once per disc and keep what it reports
    local disc=$1 f
    [[ -v D_EPS[$disc] ]] && return
    D_EPS[$disc]= D_TITLES[$disc]=
    while read -r -a f; do
        case ${f[0]} in
            episodes) D_EPS[$disc]=${f[*]:1} ;;
            title) local k="$disc|${f[1]}"
                   D_TITLES[$disc]+=" ${f[1]}"
                   T_SECS[$k]=${f[3]} T_CH[$k]=${f[5]} T_VIDEO[$k]=${f[7]/\// }
                   T_AUDIO[$k]=${f[9]} T_SUBS[$k]=${f[11]} ;;
            chapters) T_CHSECS["$disc|${f[1]}"]=${f[*]:2} ;;
        esac
    done < <(dvdmap --porcelain "$disc" 2>/dev/null)
}

load_vob() {    # length and audio streams of a .vob, via ffprobe when present
    local vob=$1 k="$1|" j
    T_SECS[$k]= T_AUDIO[$k]=- T_SUBS[$k]=? T_VIDEO[$k]= T_CH[$k]=
    command -v ffprobe >/dev/null && command -v jq >/dev/null || return 0
    j=$(ffprobe -v error -of json -show_entries \
        format=duration:stream=codec_type,codec_name,channels,height "$vob") || return 0
    T_SECS[$k]=$(jq -r '.format.duration // "" | split(".")[0]' <<<"$j")
    T_VIDEO[$k]=$(jq -r '[.streams[] | select(.codec_type == "video") | .height][0] // ""
                         | if . == 576 then "PAL" elif . == 480 then "NTSC" else "" end' <<<"$j")
    T_AUDIO[$k]=$(jq -r '[.streams | map(select(.codec_type == "audio")) | to_entries[]
                          | "\(.key + 1):und:\(.value.codec_name | ascii_upcase):\(.value.channels)"]
                         | if length == 0 then "-" else join(",") end' <<<"$j")
}

# ---------- audio track lists ----------

# resolve LIST against one title's audio streams (n:lang:codec:ch[:note],...)
# and print the chosen track numbers, comma-separated (empty = no audio)
resolve_audio() {
    local list=$1 streams=$2 item s n lang chosen=() seen=" "
    local -a all=() items
    [[ $streams == - ]] || IFS=, read -r -a all <<<"$streams"
    IFS=, read -r -a items <<<"$list"
    for item in "${items[@]}"; do
        for s in "${all[@]}"; do
            n=${s%%:*} lang=${s#*:} lang=${lang%%:*}
            [[ $seen == *" $n "* ]] && continue
            case $item in
                none) ;;
                all|any) chosen+=("$n") seen+="$n " ;;
                *[!0-9]*) [[ $item == "$lang" ]] && { chosen+=("$n") seen+="$n "; } ;;
                *) [[ $item == "$n" ]] && { chosen+=("$n") seen+="$n "; } ;;
            esac
        done
    done
    (IFS=,; echo "${chosen[*]}")
}

# what the preset itself would keep, for showing in the plan
preset_audio_list() {
    [[ -n $PJSON ]] || { echo ""; return; }
    jq -r '(.AudioLanguageList // []) as $l
           | (if ($l | length) == 0 then ["any"] else $l end | join(",")) as $langs
           | .AudioTrackSelectionBehavior + " " + $langs' <<<"$PJSON"
}

# HandBrakeCLI audio arguments for tracks "2,1" of a title with audio
# streams $2, repeating the preset's audio encoder settings (HandBrakeCLI
# drops them once -a is given) and naming each track as the GUI would
audio_args() {
    local tracks=$1 streams=$2 t first=1 row secondary q= enc mix src ch
    local -a rows tl A=() E=() M=() B=() R=() D=() G=() N=()
    [[ -z $tracks ]] && { echo "-a"; echo "none"; return; }
    [[ -n $PJSON ]] || { echo "-a"; echo "$tracks"; return; }
    secondary=$(jq -r '.AudioSecondaryEncoderMode // true' <<<"$PJSON")
    mapfile -t rows < <(jq -c '.AudioList[]' <<<"$PJSON")
    IFS=, read -r -a tl <<<"$tracks"
    for t in "${tl[@]}"; do
        for row in "${rows[@]}"; do
            enc=$(jq -r .AudioEncoder <<<"$row")
            mix=$(jq -r '.AudioMixdown // "none"' <<<"$row")
            src=$(tr , '\n' <<<"$streams" | awk -F: -v t="$t" '$1 == t { print $4 }')
            case $enc:$mix in
                copy*) ch=${src:-2} ;;
                *:mono|*:left_only|*:right_only) ch=1 ;;
                *:stereo|*:dpl1|*:dpl2) ch=2 ;;
                *) ch=6 ;;
            esac
            [[ -n $src ]] && ((src < ch)) && ch=$src
            A+=("$t") E+=("$enc") M+=("$mix")
            N+=("$( ((ch == 1)) && echo Mono || { ((ch == 2)) && echo Stereo || echo Surround; } )")
            if [[ $(jq -r '.AudioTrackQualityEnable // false' <<<"$row") == true ]]; then
                B+=("$(jq -r '.AudioTrackQuality' <<<"$row")") q=1
            else
                B+=("$(jq -r '.AudioBitrate // 0' <<<"$row")")
            fi
            R+=("$(jq -r '.AudioSamplerate // "auto"' <<<"$row")")
            D+=("$(jq -r '.AudioTrackDRCSlider // 0' <<<"$row")")
            G+=("$(jq -r '.AudioTrackGainSlider // 0' <<<"$row")")
            ((first == 0)) && [[ $secondary == true ]] && break
        done
        first=0
    done
    local IFS=,
    printf '%s\n' -a "${A[*]}" -E "${E[*]}" -6 "${M[*]}" -R "${R[*]}" -D "${D[*]}" --gain "${G[*]}" \
        -A "${N[*]}"
    if [[ -n $q ]]; then printf '%s\n' -Q "${B[*]}"; else printf '%s\n' -B "${B[*]}"; fi
}

# ---------- plan display ----------

hms() { [[ -n $1 ]] && printf '%d:%02d:%02d' $(($1 / 3600)) $(($1 / 60 % 60)) $(($1 % 60)) || printf '   ?   '; }

fmt_audio() {   # 2:eng:AC3:6:commentary -> "2 eng AC3 5.1 (commentary)"
    local s out=() n lang codec ch note
    [[ $1 == - ]] && { echo "none"; return; }
    IFS=, read -r -a ss <<<"$1"
    for s in "${ss[@]}"; do
        IFS=: read -r n lang codec ch note <<<"$s"
        [[ $ch == 6 ]] && ch=5.1 || ch=$ch.0
        out+=("$n $lang $codec $ch${note:+ (${note//-/ })}")
    done
    local IFS=$'\x1f'; echo "${out[*]}" | sed 's/\x1f/, /g'
}

fmt_subs() {
    local s out=() n lang note
    [[ $1 == - ]] && { echo "none"; return; }
    [[ $1 == "?" ]] && { echo "(not listed for VOB files)"; return; }
    IFS=, read -r -a ss <<<"$1"
    for s in "${ss[@]}"; do
        IFS=: read -r n lang note <<<"$s"
        out+=("$n $lang${note:+ (${note//-/ })}")
    done
    local IFS=$'\x1f'; echo "${out[*]}" | sed 's/\x1f/, /g'
}

# ---------- chapter splits ----------

# chapter ranges "lo-hi" for one title with $2 chapters, from SPEC $1:
# a count N gives consecutive groups of N (a short leftover group is
# kept and flagged in the plan); explicit ranges are checked against $2
chapter_ranges() {
    local spec=$1 nch=$2 lo hi r out=()
    if [[ $spec =~ ^[0-9]+$ ]]; then
        local n=$((10#$spec))
        for ((lo = 1; lo <= nch; lo += n)); do
            hi=$((lo + n - 1)); ((hi > nch)) && hi=$nch
            out+=("$lo-$hi")
        done
    else
        IFS=, read -r -a out <<<"$spec"
        for r in "${out[@]}"; do
            [[ $r =~ ^([0-9]+)(-([0-9]+))?$ ]] || die "--chapters: bad range \"$r\" (want N or lo-hi,lo-hi,...)" 2
            lo=$((10#${BASH_REMATCH[1]})) hi=$((10#${BASH_REMATCH[3]:-${BASH_REMATCH[1]}}))
            ((lo >= 1 && lo <= hi)) || die "--chapters: bad range \"$r\"" 2
            ((hi <= nch)) || die "--chapters: range $r, but ${3:-the title} has only $nch chapters" 2
        done
        for ((i = 0; i < ${#out[@]}; i++)); do
            [[ ${out[i]} == *-* ]] || out[i]="${out[i]}-${out[i]}"
        done
    fi
    printf '%s\n' "${out[@]}"
}

# "6-6" -> "6" (HandBrake's -c takes "3" or "1-3")
hb_range() { [[ ${1%-*} == "${1#*-}" ]] && echo "${1%-*}" || echo "$1"; }

# seconds in chapters lo-hi of a title, from dvdmap's chapter lengths
chapter_secs() {   # $1 = "s1 s2 s3 ...", $2 = lo-hi
    local -a cs; read -r -a cs <<<"$1"
    local lo=${2%-*} hi=${2#*-} sum=0 c
    ((${#cs[@]})) || { echo ""; return; }
    for ((c = lo; c <= hi && c <= ${#cs[@]}; c++)); do sum=$((sum + cs[c - 1])); done
    echo "$sum"
}

# ---------- names ----------

# the longest of these titles on DISC
longest_title() {
    local disc=$1 t best= secs=-1
    for t in $2; do
        ((${T_SECS["$disc|$t"]:-0} > secs)) && best=$t secs=${T_SECS["$disc|$t"]:-0}
    done
    echo "$best"
}

# file name stem of a source: dir name, or file name without .iso/.vob
source_stem() {
    local p=${1%/}
    [[ ${p##*/} == [Vv][Ii][Dd][Ee][Oo]_[Tt][Ss] ]] && p=${p%/*}
    p=${p##*/}
    [[ ${p,,} == *.iso || ${p,,} == *.vob ]] && p=${p%.*}
    echo "$p"
}

# "furiosa__a_mad_max_saga" -> "Furiosa a Mad Max Saga"; mixed case is kept
tidy_name() {
    local s=${1//[_.-]/ } w out=() small=" a an and as at but by for in nor of on or the to vs "
    local -a words
    read -r -a words <<<"$s"
    if [[ $s != "${s,,}" ]]; then echo "${words[*]}"; return; fi
    for w in "${words[@]}"; do
        if ((${#out[@]})) && [[ $small == *" $w "* ]]; then out+=("$w"); else out+=("${w^}"); fi
    done
    echo "${out[*]}"
}

# ---------- options ----------

out=. preset=$DEFAULT_PRESET go=0 brief=0 audio_all= movie=0 chapters=
opt_names=()
declare -A audio_for=()
opts=$(getopt -n dvd2mkv -o yo:p:a:A:n:mc:bLhV \
       --long yes,output:,preset:,audio:,audio-for:,name:,movie,chapters:,brief,list-presets,help,version \
       -- "$@") ||
    { echo "Try 'dvd2mkv --help'." >&2; exit 2; }
eval set -- "$opts"
afor=()
while true; do
    case $1 in
        -y|--yes) go=1 ;;
        -o|--output) out=$2; shift ;;
        -p|--preset) preset=$2; shift ;;
        -a|--audio) audio_all=$2; shift ;;
        -A|--audio-for) afor+=("$2"); shift ;;
        -n|--name) opt_names+=("$2"); shift ;;
        -m|--movie) movie=1 ;;
        -c|--chapters) chapters=$2; shift ;;
        -b|--brief) brief=1 ;;
        -L|--list-presets) list_presets; exit 0 ;;
        -h|--help) usage; exit 0 ;;
        -V|--version) echo "dvd2mkv $VERSION"; exit 0 ;;
        --) shift; break ;;
    esac
    shift
done
(($# >= 1)) || { usage >&2; exit 2; }
if [[ -n $chapters ]]; then
    ((movie)) && die "--chapters is for episode discs; it cannot be combined with --movie" 2
    [[ $chapters =~ ^[0-9]+$ ]] && ((10#$chapters == 0)) && die "--chapters: N must be at least 1" 2
    [[ $chapters =~ ^[0-9]+(-[0-9]+)?(,[0-9]+(-[0-9]+)?)*$ ]] ||
        die "--chapters wants N or ranges like 1-4,5-8,9-12" 2
fi

# a first argument that is a plain file (not .iso/.vob) is the titles file
titles=
if [[ -f $1 && ${1,,} != *.iso && ${1,,} != *.vob ]]; then
    titles=$1; shift
fi
(($# >= 1)) || die "no SOURCE given" 2
names=() names_from=
if [[ -n $titles ]]; then
    ((${#opt_names[@]})) && die "give names with a titles file or --name, not both" 2
    mapfile -t names < <(sed 's/^[[:space:]]*//; s/[[:space:]]*$//' "$titles" | grep -v '^$' || true)
    names_from="names in $titles"
elif ((${#opt_names[@]})); then
    names=("${opt_names[@]}") names_from="--name options"
fi

PJSON=$(preset_json "$preset" || true)
if [[ -z $PJSON ]] && presets_file >/dev/null && command -v jq >/dev/null &&
   ! jq -e --arg n "$preset" '[.. | objects | select(.PresetName? == $n)] | length > 0' \
        "$(presets_file)" >/dev/null; then
    die "no HandBrake preset named \"$preset\" (see dvd2mkv --list-presets)" 2
fi
[[ -z $PJSON && ( -n $audio_all || ${#afor[@]} -gt 0 ) ]] &&
    echo "dvd2mkv: warning: can't read preset \"$preset\"; HandBrake will pick its own audio encoder" >&2

# ---------- build the episode list ----------

srcs=() kinds=() nums=() chaps=() notes=()
for arg in "$@"; do
    list=
    if [[ $arg =~ ^(.+):([0-9]+(,[0-9]+)*)$ && ! -e $arg ]]; then
        arg=${BASH_REMATCH[1]} list=${BASH_REMATCH[2]//,/ }
        is_disc "$arg" || die "$arg: a :TITLES list needs a single DVD mirror or ISO"
    fi
    found=$(classify "$arg") || exit
    mapfile -t found <<<"$found"
    if ((${#found[@]} > 1)) || [[ ${found[0]#* } != "$arg" ]]; then
        notes+=("$arg: found ${#found[@]} $([[ ${found[0]%% *} == disc ]] && echo "DVDs" || echo "VOB files")")
    fi
    count=0
    for f in "${found[@]}"; do
        kind=${f%% *} path=${f#* }
        [[ -t 2 ]] && printf '\rscanning %d/%d: %s\e[K' $((++count)) ${#found[@]} "${path##*/}" >&2
        if [[ $kind == vob ]]; then
            [[ -n $chapters ]] && die "--chapters needs DVD titles; $path is a VOB file" 2
            load_vob "$path"
            srcs+=("$path") kinds+=(vob) nums+=("") chaps+=("")
            continue
        fi
        load_disc "$path"
        eps=${list:-${D_EPS[$path]}}
        if ((movie)) && [[ -z $list ]]; then
            eps=$(longest_title "$path" "${D_EPS[$path]:-${D_TITLES[$path]}}")
        fi
        [[ -n $eps ]] || die "dvdmap found no episodes on $path; give titles as $path:N,N"
        for t in $eps; do
            [[ -v T_SECS["$path|$t"] ]] || die "$path has no title $t"
            if [[ -z $chapters ]]; then
                srcs+=("$path") kinds+=(disc) nums+=("$t") chaps+=("")
                continue
            fi
            # episodes are chapter ranges of this title
            nch=${T_CH["$path|$t"]:-0}
            ((nch >= 1)) || die "$path title $t has no chapters to split"
            ranges=$(chapter_ranges "$chapters" "$nch" "${path##*/} title $t") || exit
            for r in $ranges; do
                srcs+=("$path") kinds+=(disc) nums+=("$t") chaps+=("$r")
            done
        done
    done
done
[[ -t 2 ]] && printf '\r\e[K' >&2
n=${#srcs[@]}

# output file stems (no .mkv) and title tags
bases=()
for ((i = 0; i < n; i++)); do
    if ((i < ${#names[@]})); then
        ((movie)) && bases[i]=${names[i]//\//-} ||
            bases[i]=$(printf '%02d - %s' $((i + 1)) "${names[i]//\//-}")
    elif ((movie)) && [[ -z $names_from ]]; then
        bases[i]=$(source_stem "${srcs[i]}") names[i]=$(tidy_name "${bases[i]}")
    fi
done
declare -A taken=()
for b in "${bases[@]}"; do
    [[ -v taken[$b] ]] && die "two episodes would both be written to \"$b.mkv\"; name them with --name" 2
    taken[$b]=1
done

# per-episode audio choices: --audio-for beats --audio beats the preset
for spec in "${afor[@]}"; do
    [[ $spec == *=* ]] || die "--audio-for wants EPISODES=LIST, e.g. 3,5-7=eng,2" 2
    eps=${spec%%=*} lst=${spec#*=}
    IFS=, read -r -a parts <<<"$eps"
    for p in "${parts[@]}"; do
        [[ $p =~ ^([0-9]+)(-([0-9]+))?$ ]] || die "--audio-for: bad episode number \"$p\"" 2
        lo=$((10#${BASH_REMATCH[1]})) hi=$((10#${BASH_REMATCH[3]:-${BASH_REMATCH[1]}}))
        ((lo >= 1 && hi <= n && lo <= hi)) || die "--audio-for: episode $p is not in 1-$n" 2
        for ((e = lo; e <= hi; e++)); do audio_for[$e]=$lst; done
    done
done

read -r pbehavior plangs <<<"$(preset_audio_list)"

# ---------- plan ----------

echo "Preset:  $preset"
[[ -n $PJSON ]] && echo "         $(preset_summary <<<"$PJSON")"
echo "Output:  $out/"
((movie)) && echo "Mode:    movie (one title per source, the longest unless given as DISC:N)"
if [[ -n $chapters ]]; then
    if [[ $chapters =~ ^[0-9]+$ ]]; then
        echo "Mode:    chapters ($chapters per episode, from each selected title)"
    else
        echo "Mode:    chapters ($chapters of each selected title)"
    fi
fi
for x in "${notes[@]}"; do echo "Source:  $x"; done
echo
prev_audio= prev_subs= prev_src=
declare -a chosen
for ((i = 0; i < n; i++)); do
    k="${srcs[i]}|${nums[i]}" e=$((i + 1))
    file=${bases[i]:+${bases[i]}.mkv}
    file=${file:-(no name)}
    [[ -n ${bases[i]:-} && ${bases[i]} != *"${names[i]//\//-}" ]] && file+="  (title \"${names[i]}\")"
    where=${srcs[i]%/}
    [[ ${where##*/} == [Vv][Ii][Dd][Ee][Oo]_[Tt][Ss] ]] && where=${where%/*}
    where=${where##*/}
    [[ ${kinds[i]} == disc ]] && where+=" -t ${nums[i]}"
    ch=${T_CH[$k]:+${T_CH[$k]} chap}
    secs=${T_SECS[$k]}
    if [[ -n ${chaps[i]} ]]; then
        lo=${chaps[i]%-*} hi=${chaps[i]#*-}
        where+=" -c $(hb_range "${chaps[i]}")"
        ch="$((hi - lo + 1)) chap"
        secs=$(chapter_secs "${T_CHSECS[$k]:-}" "${chaps[i]}")
    fi
    printf '%3d  %-26s %s  %-7s  %-9s ->  %s\n' "$e" "$where" "$(hms "$secs")" \
           "$ch" "${T_VIDEO[$k]}" "$file"
    # a split that ends with a short leftover group is probably wrong
    if [[ -n ${chaps[i]} && $chapters =~ ^[0-9]+$ ]] && ((hi - lo + 1 < 10#$chapters)); then
        echo "       ^ only $((hi - lo + 1)) chapter(s) left at the end of title ${nums[i]}; check --chapters"
    fi

    how= sel=
    if [[ -v audio_for[$e] ]]; then
        how="--audio-for" sel=$(resolve_audio "${audio_for[$e]}" "${T_AUDIO[$k]}")
    elif [[ -n $audio_all ]]; then
        how="--audio" sel=$(resolve_audio "$audio_all" "${T_AUDIO[$k]}")
    elif [[ -n $pbehavior ]]; then
        how="preset" sel=$(resolve_audio "$plangs" "${T_AUDIO[$k]}")
        [[ $pbehavior == none ]] && sel=
        [[ $pbehavior == first ]] && sel=${sel%%,*}
    fi
    chosen[i]=$how:$sel
    if ((brief)); then
        [[ $how == --audio* ]] && echo "       audio: keep ${sel:-NO AUDIO} ($how)"
        continue
    fi
    if [[ ${T_AUDIO[$k]} == "$prev_audio" && ${srcs[i]} == "$prev_src" ]]; then
        a="same as above"
    else
        a=$(fmt_audio "${T_AUDIO[$k]}")
    fi
    if [[ $how == preset && ${T_AUDIO[$k]} == - ]]; then keep=
    elif [[ -n $how ]]; then keep="  =>  keep ${sel:-NO AUDIO} ($how)"
    else keep=
    fi
    echo "       audio: $a$keep"
    if [[ ${T_SUBS[$k]} != "$prev_subs" || ${srcs[i]} != "$prev_src" ]]; then
        echo "       subs:  $(fmt_subs "${T_SUBS[$k]}")"
    fi
    prev_audio=${T_AUDIO[$k]} prev_subs=${T_SUBS[$k]} prev_src=${srcs[i]}
done

if [[ -z $names_from ]] && ((!movie)); then
    echo; echo "(no names: give a titles file or --name to encode; plan only)"; exit 0
fi
if [[ -n $names_from ]] && ((n != ${#names[@]})); then
    echo
    die "count mismatch: $n episodes found but ${#names[@]} $names_from"
fi
if ((!go)); then echo; echo "(plan only; add -y to encode)"; exit 0; fi

# ---------- encode ----------

mkdir -p "$out"
log=$out/handbrake.log
for ((i = 0; i < n; i++)); do
    name=${names[i]} base=${bases[i]} e=$((i + 1))
    dest="$out/$base.mkv"
    if [[ -e $dest ]]; then echo "skip (exists): $base.mkv"; continue; fi
    args=(--preset-import-gui -Z "$preset" --format av_mkv -i "${srcs[i]}" -o "$out/.partial.mkv")
    [[ ${kinds[i]} == disc ]] && args+=(-t "${nums[i]}")
    [[ -n ${chaps[i]} ]] && args+=(-c "$(hb_range "${chaps[i]}")")
    how=${chosen[i]%%:*} sel=${chosen[i]#*:}
    if [[ $how == --audio* ]]; then
        mapfile -t aa < <(audio_args "$sel" "${T_AUDIO[${srcs[i]}|${nums[i]}]}")
        args+=("${aa[@]}")
    fi
    printf '[%s] %d/%d  %s ...' "$(date +%H:%M)" "$e" "$n" "$base.mkv"
    SECONDS=0
    "${HB[@]}" "${args[@]}" </dev/null 2>>"$log" >/dev/null ||
        { echo; die "HandBrake failed on $base; see $log"; }
    mkvpropedit -q "$out/.partial.mkv" --edit info --set "title=$name"
    mv "$out/.partial.mkv" "$dest"
    printf ' done (%dm%02ds)\n' $((SECONDS / 60)) $((SECONDS % 60))
done
