m4b-tool/to-m4b.sh
2025-09-07 20:03:57 +02:00

184 lines
5.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)"
# 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 -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 "$@"