Skip to content

Instantly share code, notes, and snippets.

@ewilan-riviere
Last active July 6, 2025 06:23
Show Gist options
  • Select an option

  • Save ewilan-riviere/b516d8b6956dbaa183b4b0bd350f3cf6 to your computer and use it in GitHub Desktop.

Select an option

Save ewilan-riviere/b516d8b6956dbaa183b4b0bd350f3cf6 to your computer and use it in GitHub Desktop.
Split a M4B audiobook into chapters.
#!/bin/bash
# Description: Split a M4B audiobook into chapters.
# Requires: ffmpeg, ffprobe, jq, yq
# Author: Hasan Arous
# Improved by: Ewilan Rivière
# License: MIT
# https://unix.stackexchange.com/questions/499179/using-ffmpeg-to-split-an-audible-audio-book-into-chapters
# Usage: ./m4b-splitter.sh <input_file.m4b> [--no-convert | -n]
# Enable script execution: `chmod +x ./m4b-splitter.sh`
# Run the script: `./m4b-splitter.sh mybook.m4b`
# This script splits an M4B audiobook file into chapters, extracts metadata to YAML,
# and extracts the cover image if available. It can also convert chapters to MP3 format.
# To skip MP3 conversion and keep chapters as M4B:
# `./m4b-splitter.sh input.m4b --no-convert`
# or
# `./m4b-splitter.sh input.m4b -n`
# Based on `Audiobook Builder 2` audiobook construction https://apps.apple.com/fr/app/audiobook-builder-2/id1437681957
set -euo pipefail
show_help() {
cat <<EOF
Split an audiobook file into chapters, export metadata.yaml, extract cover image.
Requirements:
- ffmpeg, ffprobe, jq, yq
Options:
--no-convert, -n Skip conversion of chapters to MP3 (keep as M4B)
Usage:
$(basename "$0") <input_file.m4b> [output_extension]
Example:
$(basename "$0") mybook.m4b
EOF
}
if [[ $# -eq 0 || "$1" == "-h" || "$1" == "--help" ]]; then
show_help
exit 0
fi
input="$1"
input_base=$(basename "$input")
input_name="${input_base%.*}"
shift || true # Remove first arg (input file)
convert_mp3=true
# Parse optional flags
while (($#)); do
case "$1" in
--no-convert | -n)
convert_mp3=false
shift
;;
*)
echo "Unknown option: $1" >&2
exit 1
;;
esac
done
echo "Convert chapters to MP3: ${convert_mp3}"
for cmd in ffmpeg ffprobe jq yq; do
if ! command -v "$cmd" &>/dev/null; then
echo "Error: '$cmd' is not installed or not in PATH." >&2
exit 1
fi
done
output_dir="${input_name}_chapters"
# Delete output directory if it exists
if [[ -d "$output_dir" ]]; then
echo "⚠️ Output directory '$output_dir' exists, deleting it..."
rm -rf "$output_dir"
fi
mkdir -p "$output_dir"
# --- Extract chapters and split ---
chapters=$(ffprobe -v error -print_format json -show_chapters "$input" | jq -c '.chapters[]')
count=1
echo "πŸ“‚ Splitting chapters into: $output_dir"
echo
ffprobe -v error -print_format json -show_chapters "$input" | jq -c '.chapters[]' | while read -r chapter; do
start=$(echo "$chapter" | jq -r '.start_time')
end=$(echo "$chapter" | jq -r '.end_time')
title=$(echo "$chapter" | jq -r '.tags.title // "Chapter"')
# safe_title=$(echo "$title" | tr -cd '[:alnum:] _-' | tr ' ' '_' | tr -s '_')
safe_title=$title
output_file="$output_dir/${count}_${safe_title}.m4b"
echo "🎡 Chapter $count: $safe_title"
ffmpeg -loglevel error -y -i "$input" -ss "$start" -to "$end" -c copy "$output_file"
((count++))
done
# --- Export all metadata to YAML (full) ---
echo "πŸ’Ύ Exporting full metadata to YAML..."
ffprobe -v error -print_format json -show_format -show_streams -show_chapters "$input" >"$output_dir/metadata.json"
# --- Extract tags only (format tags + chapter titles) for tags.yaml ---
echo "πŸ’Ύ Extracting tags to tags.yaml..."
# Extract general format tags as JSON object
format_tags_json=$(jq '.format.tags // {}' "$output_dir/metadata.json")
# Extract chapter titles array, key = Chapter_<number>, value = title or default
chapter_titles_json=$(jq '
.chapters
| map(
{ ("Chapter_" + (.id|tostring)): (.tags.title // "Untitled") }
)
| add
' "$output_dir/metadata.json")
# Merge format tags and chapter titles into one JSON object
merged_tags_json=$(jq -n --argjson f "$format_tags_json" --argjson c "$chapter_titles_json" '$f + $c')
# Convert merged JSON tags to YAML
echo "$merged_tags_json" | yq -P e - >"$output_dir/tags.yaml"
# --- Extract cover image (if any) ---
echo
echo "πŸ–ΌοΈ Detecting cover image stream..."
cover_stream_index=$(jq -r '.streams[] | select(.disposition.attached_pic == 1) | .index' "$output_dir/metadata.json" | head -n1 || echo "")
if [[ -n "$cover_stream_index" ]]; then
echo "Extracting cover image from stream $cover_stream_index to $output_dir/folder.jpg"
ffmpeg -hide_banner -loglevel quiet -y -i "$input" -map "0:$cover_stream_index" -c copy "$output_dir/folder.jpg"
echo "βœ… Cover image extracted successfully."
else
echo "⚠️ No cover image stream found, skipping cover extraction."
fi
echo
echo "Converting in parallel..."
# Conditional MP3 conversion
if [ "$convert_mp3" = true ]; then
echo
echo "πŸ”„ Converting all chapter .m4b files to .mp3 in parallel (fast encoding) and deleting originals..."
max_jobs=4
job_count=0
quality=2 # Adjust quality as needed (1 = best, 9 = worst)
find "$output_dir" -name '*.m4b' | while IFS= read -r m4bfile; do
(
mp3file="${m4bfile%.m4b}.mp3"
echo "Converting '$m4bfile' β†’ '$mp3file'"
ffmpeg -loglevel error -y -i "$m4bfile" -acodec libmp3lame -qscale:a "$quality" "$mp3file" && rm "$m4bfile"
) &
((job_count++))
if ((job_count >= max_jobs)); then
wait
job_count=0
fi
done
wait
echo "βœ… Conversion and cleanup complete."
else
echo
echo "⚠️ MP3 conversion skipped (--no-convert or -n). Output files are .m4b chapters."
fi
echo
echo "βœ… Done! All files saved to: $output_dir"
@ewilan-riviere
Copy link
Author

Enable script execution:

sudo chmod +x ./m4b-splitter.sh

Usage:

./m4b-splitter.sh input.m4b

Usage without conversion to MP3:

./m4b-splitter.sh input.m4b --no-convert # or
./m4b-splitter.sh input.m4b -n

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment