#!/bin/sh
# Convert M4B/M4A files to AAC-LC, preserving tags, chapters and artwork.
#
# -j count: concurrent conversions (default 2).
# -b kbps: total audio bitrate, a positive integer in kb/s (default 96).
# -c channels: 1 for mono, 2 for stereo (default 2).
# -o file: exact output path, for one input only; use .m4b or .m4a.
# -d directory: existing output directory; keep input stems and extension type.
# -s suffix: append this to generated stems (default .aac-lc, or empty with -d).
# -o cannot be combined with -d or -s. Output directories must already exist.
#
# One file, several files, or several patterns:
#   ./m4b2aac.sh -- './Book One.m4b'
#   ./m4b2aac.sh -j 4 -- './Book One.m4b' './Book Two.m4a'
#   ./m4b2aac.sh -j 4 -- ./originals/*.m4b ./more/*.m4a
#
# Bitrate choices: 96 stereo for quality margin; 64 stereo for smaller files;
# 48-64 mono for narration.
#   ./m4b2aac.sh -b 48 -c 1 -s '.mono' -- './Book.m4b'
#
# Exact output name, custom suffix, or a separate output directory:
#   ./m4b2aac.sh -o './Book LC.m4b' -- './Book.m4b'
#   ./m4b2aac.sh -s '.rockbox' -- ./originals/*.m4b
#   mkdir -p ./m4b-aac-lc &&
#   ./m4b2aac.sh -d ./m4b-aac-lc -j 4 -- ./*.m4b
#   ./m4b2aac.sh -d ./m4b-aac-lc -s '.lc' -- './Book.m4b'
#
# Manual batch changing .m4a to .m4b; stop at the first error:
#   mkdir -p ./converted &&
#   for file in ./originals/*.m4a; do
#       name=${file##*/}
#       ./m4b2aac.sh -o "./converted/${name%.*}.m4b" -- "$file" || break
#   done
#
# Recursive conversion beside originals; exclude staging and generated files:
#   find ./books -type d -name '.m4b2aac.*' -prune -o \
#       -type f -name '*.[mM]4[aAbB]' ! -name '*.aac-lc.m4[ab]' \
#       -exec /absolute/path/m4b2aac.sh -j 4 -- {} +
# With -d, filenames are flattened; output filenames must be distinct.
# Use non-overlapping input patterns, and exclude outputs on subsequent runs.

usage() {
    printf 'Usage: %s [-j jobs] [-b kbps] [-c 1|2] [-d directory] [-s suffix] [--] file ...\n       %s [-j jobs] [-b kbps] [-c 1|2] -o output [--] file\n' "$0" "$0" >&2
    exit 2
}
jobs=2
bitrate=96
channels=2
output_file=
target_dir=
suffix=.aac-lc
suffix_set=
while getopts 'j:b:c:o:d:s:' option; do
    case $option in
        j) jobs=$OPTARG ;;
        b) bitrate=$OPTARG ;;
        c) channels=$OPTARG ;;
        o) output_file=$OPTARG; [ -n "$output_file" ] || usage ;;
        d) target_dir=$OPTARG; [ -n "$target_dir" ] || usage ;;
        s) suffix=$OPTARG; suffix_set=1 ;;
        *) usage ;;
    esac
done
shift "$((OPTIND - 1))"
for count in "$jobs" "$bitrate"; do
    case $count in ''|*[!0-9]*) usage ;; esac
    [ "$count" -gt 0 ] 2>/dev/null || usage
done
case $channels in 1|2) ;; *) usage ;; esac
[ "$#" -gt 0 ] || usage
case $suffix in */*) usage ;; esac
if [ -n "$output_file" ]; then
    [ "$#" -eq 1 ] && [ -z "$target_dir$suffix_set" ] || usage
    case $output_file in *.[mM]4[aAbB]) ;; *) usage ;; esac
    case $output_file in /*|./*|../*) ;; *) output_file=./$output_file ;; esac
fi
if [ -n "$target_dir" ]; then
    case $target_dir in /*|./*|../*) ;; *) target_dir=./$target_dir ;; esac
    [ -d "$target_dir" ] || {
        printf 'Not a directory: %s\n' "$target_dir" >&2; exit 1
    }
    [ -n "$suffix_set" ] || suffix=
fi
for tool in ffmpeg mktemp; do
    command -v "$tool" >/dev/null 2>&1 || {
        printf 'Missing command: %s\n' "$tool" >&2; exit 1
    }
done

paths() {
    # Prefix bare paths so FFmpeg and POSIX utilities cannot read them as options.
    case $1 in /*|./*|../*) file=$1 ;; *) file=./$1 ;; esac
    name=${file##*/}
    case $name in *.[mM]4[aA]) extension=m4a ;; *) extension=m4b ;; esac
    directory=${target_dir:-${file%/*}}
    output=${output_file:-${directory%/}/${name%.*}$suffix.$extension}
    directory=${output%/*}
    [ -n "$directory" ] || directory=/
    name=${output##*/}
}

work=$(mktemp -d "${TMPDIR:-/tmp}/m4b2aac.XXXXXX") || exit 1
token=.${work##*/}
pending=
status=0
polls=0
# shellcheck disable=SC2329 # Called by the exit trap.
cleanup() {
    trap '' HUP INT TERM
    # Entries contain only a child PID and a numeric job ID.
    for job in $pending; do
        [ -f "$work/${job#*:}.done" ] || kill -s TERM "${job%:*}" 2>/dev/null || :
    done
    for job in $pending; do wait "${job%:*}" 2>/dev/null || :; done
    for record in "$work"/*.directory; do
        [ -f "$record" ] || continue
        stage=$(cat "$record") || continue
        case $stage in */"$token") rm -rf "$stage" ;; esac
    done
    rm -rf "$work"
}
trap cleanup 0
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

# Reserve names on each destination filesystem before starting any encoders.
# A shared random name also recognises directory aliases without parsing paths.
printf '%s\n' "$work" >"$work/owner" || exit 1
id=0
for file do
    id=$((id + 1))
    case $file in *.[mM]4[aAbB]) ;; *) continue ;; esac
    [ -f "$file" ] || continue
    paths "$file"
    [ -e "$output" ] || [ -L "$output" ] || {
        stage=$directory/$token
        if mkdir -m 700 "$stage" 2>/dev/null; then
            printf '%s' "$stage" >"$work/$id.directory" || {
                rm -f "$work/$id.directory"
                rmdir "$stage"; exit 1
            }
            cp "$work/owner" "$stage/owner" && mkdir "$stage/names" || exit 1
        elif ! cmp -s "$work/owner" "$stage/owner"; then
            printf 'Cannot stage output in: %s\n' "$directory" >&2; exit 1
        fi
        mkdir "$stage/names/$name" 2>/dev/null || {
            printf 'Conflicting or invalid output filename: %s\n' "$output" >&2; exit 1
        }
    }
done

# The background invocation supplies the subshell; avoid another process layer.
convert() {
    id=$1
    paths "$2"
    encoder=
    # shellcheck disable=SC2329 # Called by the exit trap.
    finish() {
        trap '' HUP INT TERM
        if [ -n "$encoder" ]; then
            kill -s TERM "$encoder" 2>/dev/null || :
            wait "$encoder" 2>/dev/null || :
        fi
        : >"$work/$id.done"
    }
    trap finish 0
    trap 'exit 129' HUP
    trap 'exit 130' INT
    trap 'exit 143' TERM

    case $file in
        *.[mM]4[aAbB]) ;;
        *) printf 'Expected an M4B/M4A file: %s\n' "$file" >&2; exit 1 ;;
    esac
    [ -f "$file" ] || {
        printf 'Not a regular file: %s\n' "$file" >&2; exit 1
    }
    if [ -e "$output" ] || [ -L "$output" ]; then
        printf 'Exists, skipping: %s\n' "$output" >&2; exit 0
    fi
    tmp=$directory/$token/$id
    mkdir "$tmp" || exit 1
    printf '[%s] Started: %s\n' "$id" "$file" >&2
    # Separate inputs avoid FFmpeg 9 truncating these books when copying artwork.
    ffmpeg -hide_banner -nostdin -nostats -v error -xerror -n \
        -stats_period 15 -progress "$work/$id.progress" \
        -i "$file" -i "$file" -map 0:a:0 -map '1:v:disp:attached_pic?' \
        -af 'aresample=async=1:first_pts=0' \
        -c:a aac -profile:a aac_low -ar 24000 \
        -b:a "${bitrate}k" -ac "$channels" -c:v copy \
        "$tmp/$name" >"$tmp/log" 2>&1 &
    encoder=$!
    result=0
    wait "$encoder" || result=$?
    encoder=
    if [ "$result" -ne 0 ] || [ ! -s "$tmp/$name" ]; then
        printf '[%s] Failed: %s\n' "$id" "$file" >&2
        cat "$tmp/log" >&2
        exit 1
    fi
    # EOF declines overwrite if a destination appeared during encoding.
    mv -i "$tmp/$name" "$directory/" </dev/null &&
        [ ! -f "$tmp/$name" ] || {
        printf '[%s] Cannot publish: %s\n' "$id" "$output" >&2; exit 1
    }
    printf '[%s] Created: %s\n' "$id" "$output"
}

reap() {
    remaining=
    active=0
    polls=$(((polls + 1) % 15))
    for job in $pending; do
        if [ -f "$work/${job#*:}.done" ] || ! kill -s 0 "${job%:*}" 2>/dev/null; then
            wait "${job%:*}" || status=1
        else
            remaining="$remaining $job"
            active=$((active + 1))
            if [ "$polls" -eq 0 ] && [ -f "$work/${job#*:}.progress" ]; then
                elapsed=$(sed -n 's/^out_time=//p' "$work/${job#*:}.progress" | tail -n 1)
                [ -z "$elapsed" ] ||
                    printf '[%s] Encoded: %s\n' "${job#*:}" "${elapsed%.*}" >&2
            fi
        fi
    done
    pending=$remaining
}
id=0
active=0
for file do
    while [ "$active" -ge "$jobs" ]; do
        reap
        [ "$active" -lt "$jobs" ] || sleep 1
    done
    id=$((id + 1))
    convert "$id" "$file" &
    pending="$pending $!:$id"
    active=$((active + 1))
done
while [ -n "$pending" ]; do
    reap
    [ -z "$pending" ] || sleep 1
done
exit "$status"
