#!/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 -a results=() local dir leaf # 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 if compgen -G "$dir"*.{mp3,m4a,m4b,aac,flac,wav} >/dev/null 2>&1; then results+=("${dir%/}") fi done printf '%s\n' "${results[@]}" } 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} mkdir -p "$(dirname "${output_file}")" nix run github:sandreas/m4b-tool#m4b-tool-libfdk -- \ merge \ -v \ --jobs=6 \ --audio-samplerate=44100 \ --audio-quality=100 \ --writer="${author}" \ --artist="${narrator}" \ --title="${title}" \ --year="${year}" \ --album="${series}" \ --album-sort="${series_index}" \ --output-file="${output_file}/" \ -- "${source_dir}" } 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 mapfile -t dirs < <(get-book-directories "${src_root}") 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}/${series_index:+${series_index} - }${title}${narrator:+ {${narrator}}}.m4b" else output_file="${out_root}/${author}/${year:+${year} - }${title}${narrator:+ {${narrator}}}.m4b" fi echo "Processing '$dir' -> '$output_file'" # m4b-merge "$output_file" "$dir" "$author" "$narrator" "$title" "$year" "$series" "$series_index" done } main "$@"