#!/usr/bin/env bash # set -x set -eufo pipefail # CONSTANTS declare -r script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # VARS LEADING_ZEROES=2 SRC="${script_dir}/src" OUT="${script_dir}/out" # FUNCTIONS parse-vars() { # Series structure: ${SRC}///Book - - {}/ # Standalone structure: ${SRC}// - {}/ # Return variables as key=value lines; zero-padded to LEADING_ZEROES as series_index local dir="${1%/}" local leaf="${dir##*/}" local parent="${dir%/*}" local grandparent="${parent%/*}" local author="" local series="" local title="" local year="" local narrator="" local part="" local series_index="" local rest="" # Helper: trim leading/trailing whitespace trim() { local s="$1"; s="${s#${s%%[![:space:]]*}}"; s="${s%${s##*[![:space:]]}}"; printf '%s' "$s"; } # Detect pattern and extract fields if [[ "$leaf" =~ ^[Bb]ook[[:space:]]+([0-9]+)[[:space:]]*-[[:space:]]*([0-9]{4})[[:space:]]*-[[:space:]]*(.+)$ ]]; then part="${BASH_REMATCH[1]}" year="${BASH_REMATCH[2]}" rest="${BASH_REMATCH[3]}" if [[ "$rest" =~ ^(.+)[[:space:]]*\{([^}]*)\}[[:space:]]*$ ]]; then title="${BASH_REMATCH[1]}" narrator="${BASH_REMATCH[2]}" else title="$rest" fi series="${parent##*/}" author="${grandparent##*/}" elif [[ "$leaf" =~ ^([0-9]{4})[[:space:]]*-[[:space:]]*(.+)$ ]]; then year="${BASH_REMATCH[1]}" rest="${BASH_REMATCH[2]}" if [[ "$rest" =~ ^(.+)[[:space:]]*\{([^}]*)\}[[:space:]]*$ ]]; then title="${BASH_REMATCH[1]}" narrator="${BASH_REMATCH[2]}" else title="$rest" fi author="${parent##*/}" else # Fallback best-effort parsing rest="$leaf" if [[ "$rest" =~ \{([^}]*)\}[[:space:]]*$ ]]; then narrator="${BASH_REMATCH[1]}" rest="${rest%\{${narrator}\}*}" fi if [[ "$rest" =~ ^([0-9]{4})[[:space:]]*-[[:space:]]*(.+)$ ]]; then year="${BASH_REMATCH[1]}" title="${BASH_REMATCH[2]}" else title="$rest" fi author="${parent##*/}" fi # Normalize whitespace title="$(trim "$title")" author="$(trim "$author")" series="$(trim "$series")" narrator="$(trim "$narrator")" # Zero-pad part for series_index if available if [[ -n "$part" ]]; then part="${part//[^0-9]/}" series_index=$(printf "%0*d" "$LEADING_ZEROES" "$part") fi printf 'author=%q\n' "$author" printf 'series=%q\n' "$series" printf 'series_index=%q\n' "$series_index" printf 'title=%q\n' "$title" printf 'year=%q\n' "$year" printf 'narrator=%q\n' "$narrator" } get-book-directories() { # Discover book directories under ${SRC} (or ${script_dir}/src if unset) # Matches: # - ${SRC}///Book - - {<narrator>}/ # - ${SRC}/<author>/<year> - <title> {<narrator>}/ # Prints matched directories, one per line local src_root="${1}" local dir leaf # Ensure globbing is enabled locally (script uses set -f globally) local had_noglob=0 case $- in *f*) had_noglob=1 ;; esac set +f # Iterate candidate depths using fast globbing (avoids slow full-recursive find) for dir in "${src_root}"/*/*/ "${src_root}"/*/*/*/; do [[ -d "$dir" ]] || continue leaf="${dir%/}"; leaf="${leaf##*/}" # Match standalone: "YYYY - Title {Narrator}" or similar if [[ "$leaf" =~ ^[0-9]{4}[[:space:]]*-[[:space:]]*.+$ ]]; then : # Match series: "Book N - YYYY - Title {Narrator}" (Book/book, flexible spaces) elif [[ "$leaf" =~ ^[Bb]ook[[:space:]]+[0-9]+[[:space:]]*-[[:space:]]*[0-9]{4}[[:space:]]*-[[:space:]]*.+$ ]]; then : else continue fi # Quick check: directory contains at least one likely audio file local found=0 ext for ext in mp3 m4a m4b aac flac wav ogg; do if compgen -G "$dir"*."$ext" >/dev/null 2>&1; then found=1 break fi done (( found )) && printf '%s\n' "${dir%/}" done # Restore previous noglob state (( had_noglob )) && set -f || true } m4b-merge() { local output_file="${1}" local source_dir="${2}" local author="${3}" local narrator="${4}" local title="${5}" local year="${6}" local series="${7}" local series_index=${8} # Ensure destination directory exists mkdir -p "$(dirname "${output_file}")" # Build args; add series flags only when present (standalone-friendly) local args=( merge -v --jobs=6 --audio-samplerate=44100 --audio-quality=100 ) if [[ -n "${author}" ]]; then args+=("--writer=${author}") fi if [[ -n "${narrator}" ]]; then args+=("--artist=${narrator}") fi if [[ -n "${title}" ]]; then args+=("--album=${title}") fi if [[ -n "${year}" ]]; then args+=("--year=${year}") fi if [[ -n "${series}" ]]; then args+=("--series=${series}") fi if [[ -n "${series_index}" ]]; then args+=("--series-part=${series_index}") fi args+=("--output-file=${output_file}" -- "${source_dir}") nix run github:sandreas/m4b-tool#m4b-tool-libfdk -- "${args[@]}" } main() { local src_root="${SRC:-${script_dir}/src}" local out_root="${OUT:-${script_dir}/out}" local dir author narrator title year series series_index output_file # Build array of directories without using mapfile (Bash 3 compatibility) # Portable array fill (Bash 3): split on newlines only { IFS=$'\n' dirs=($(get-book-directories "${src_root}")) } if ((${#dirs[@]})); then for dir in "${dirs[@]}"; do eval "$(parse-vars "$dir")" if [[ -z "$title" ]]; then echo "Skipping '$dir': could not parse title" >&2 continue fi if [[ -z "$author" ]]; then echo "Skipping '$dir': could not parse author" >&2 continue fi # Construct output path: ${OUT}/<author>/<series or standalone>/<series_index - >title {narrator}.m4b if [[ -n "$series" ]]; then output_file="${out_root}/${author}/${series}/" [[ -n "$series_index" ]] && output_file+="Book ${series_index} - " [[ -n "$year" ]] && output_file+="${year} - " output_file+="${title}" else output_file="${out_root}/${author}/" [[ -n "$year" ]] && output_file+="${year} - " output_file+="${title}" fi [[ -n "$narrator" ]] && output_file+=" {${narrator}}" output_file+=".m4b" echo "Processing '$dir' -> '$output_file'" m4b-merge "$output_file" "$dir" "$author" "$narrator" "$title" "$year" "$series" "$series_index" done else echo "No book directories found under '${src_root}'." >&2 fi } main "$@"