229 lines
6.4 KiB
Bash
Executable file
229 lines
6.4 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# set -x
|
|
set -eufo pipefail
|
|
|
|
# CONSTANTS
|
|
declare -r script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
declare -ra m4b_tool_bin=(nix run github:sandreas/m4b-tool#m4b-tool-libfdk --)
|
|
|
|
# VARS
|
|
LEADING_ZEROES=2
|
|
SRC="${script_dir}/src"
|
|
OUT="${script_dir}/out"
|
|
|
|
# FUNCTIONS
|
|
parse-vars() {
|
|
# Series structure: ${SRC}/<author>/<series>/Book <part> - <year> - <book> {<narrator>}/
|
|
# Standalone structure: ${SRC}/<author>/<year> - <book> {<narrator>}/
|
|
# Return variables as key=value lines; <part> 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}/<author>/<series>/Book <part> - <year> - <title> {<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}")
|
|
|
|
"${m4b_tool_bin[@]}" "${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
|
|
output_file="${out_root}/${author}/"
|
|
if [[ -n "${series}" && -n "${series_index}" ]]; then
|
|
output_file+="${series}/Book ${series_index} - ${year} - ${title}"
|
|
|
|
else
|
|
output_file+="${year} - ${title}"
|
|
[[ -n "${narrator}" ]] && output_file+=" {${narrator}}/"
|
|
fi
|
|
output_file+="/${year} - ${title}"
|
|
[[ -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 "$@"
|