Last active
July 6, 2025 06:23
-
-
Save ewilan-riviere/b516d8b6956dbaa183b4b0bd350f3cf6 to your computer and use it in GitHub Desktop.
Split a M4B audiobook into chapters.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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" |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Enable script execution:
Usage:
Usage without conversion to MP3:
./m4b-splitter.sh input.m4b --no-convert # or ./m4b-splitter.sh input.m4b -n