Skip to content

Instantly share code, notes, and snippets.

@brokosz
Last active June 3, 2025 06:05
Show Gist options
  • Save brokosz/87be2f15e58aeb826d1696dd76771bdb to your computer and use it in GitHub Desktop.
Save brokosz/87be2f15e58aeb826d1696dd76771bdb to your computer and use it in GitHub Desktop.
#!/bin/bash
# Audio to M4B Audiobook Converter
# Converts multiple audio files per book into single M4B files with metadata
set -e
# Default settings
DEFAULT_BITRATE="copy"
DEFAULT_CHAPTER_LENGTH=300
VERBOSE=false
QUIET=false
OUTPUT_TO_PARENT=true
OUTPUT_DIR=""
OVERRIDE_AUTHOR=""
OVERRIDE_TITLE=""
NO_LOOKUP=false
FAST_MODE=false
FORCE_CONCAT=false
show_help() {
cat << 'EOF'
Audio to M4B Audiobook Converter
USAGE:
m4b [OPTIONS] <path>
OPTIONS:
-b, --bitrate RATE Audio bitrate (default: copy - preserves original)
-c, --chapter-time SEC Chapter length in seconds (default: 300)
-o, --output-dir DIR Output directory for M4B files
-p, --parent Output M4B to parent directory (default)
-s, --same-dir Output M4B to same directory as audio files
-a, --author AUTHOR Override author metadata
-t, --title TITLE Override title metadata
--no-lookup Disable online metadata lookup
--fast Fast container-only conversion (no transcoding)
--force-concat Force multi-file processing even for single files
-v, --verbose Show ffmpeg output and detailed progress
-q, --quiet Minimal output (errors only)
-h, --help Show this help
EXAMPLES:
m4b ~/Books/Book-Title/
m4b -s ~/Books/
m4b -a "Author Name" -t "Book Title" book-folder/
m4b -o ~/Converted ~/Books/
m4b --fast ~/Books/single-file-book/
REQUIREMENTS:
- ffmpeg with AAC support
- Each book should be in its own folder
- Audio files will be processed in alphabetical order
- curl (for online metadata lookup)
SUPPORTED FORMATS:
- MP3, AAC, M4A, MP4, FLAC, OGG, WAV
- Automatically detects and converts from any supported format
METADATA PRIORITY:
1. Manual overrides (-a, -t flags)
2. Audio metadata tags (if meaningful)
3. Multi-file filename pattern analysis
4. Folder name as fallback
OUTPUT:
Creates .m4b files with embedded chapters and metadata
EOF
}
log_info() {
if [[ "$QUIET" != "true" ]]; then
echo "$@"
fi
}
log_verbose() {
if [[ "$VERBOSE" == "true" ]]; then
echo "$@"
fi
}
get_output_path() {
local book_dir="$1"
local book_name="$2"
if [[ -n "$OUTPUT_DIR" ]]; then
echo "$OUTPUT_DIR/${book_name}.m4b"
elif [[ "$OUTPUT_TO_PARENT" == "true" ]]; then
echo "${book_dir}.m4b"
else
echo "${book_dir}/${book_name}.m4b"
fi
}
check_dependencies() {
if ! command -v ffmpeg &> /dev/null; then
echo "Error: ffmpeg not found. Install with: brew install ffmpeg"
exit 1
fi
if ! command -v curl &> /dev/null && [[ "$NO_LOOKUP" != "true" ]]; then
log_verbose "Warning: curl not found. Online lookup disabled."
NO_LOOKUP=true
fi
}
analyze_filename_patterns() {
local audio_files=("$@")
local title=""
local author=""
# Only analyze if we have multiple files
if [[ ${#audio_files[@]} -lt 2 ]]; then
echo "|"
return
fi
# Extract basenames without extensions
local basenames=()
for file in "${audio_files[@]}"; do
local basename=$(basename "$file")
# Remove common audio extensions
basename=${basename%.*}
basenames+=("$basename")
done
# Look for common prefixes that could be title/author
local first_name="${basenames[0]}"
local common_prefix=""
# Find longest common prefix across all filenames
for ((i=1; i<${#first_name}; i++)); do
local prefix="${first_name:0:i}"
local all_match=true
for name in "${basenames[@]:1}"; do
if [[ "$name" != "$prefix"* ]]; then
all_match=false
break
fi
done
if [[ "$all_match" == "true" ]]; then
common_prefix="$prefix"
else
break
fi
done
# Clean up common prefix and extract meaningful parts
if [[ -n "$common_prefix" && ${#common_prefix} -gt 10 ]]; then
# Remove trailing separators and numbers
common_prefix=$(echo "$common_prefix" | sed -E 's/[[:space:]]*[-_]*[[:space:]]*[0-9]*[[:space:]]*$//')
# Look for author - title pattern (Author - Title)
if [[ "$common_prefix" =~ ^(.+)[[:space:]]*-[[:space:]]*(.+)$ ]]; then
author="${BASH_REMATCH[1]}"
title="${BASH_REMATCH[2]}"
log_verbose "Filename pattern detected - Author: '$author', Title: '$title'" >&2
elif [[ ${#common_prefix} -gt 5 ]]; then
# Use as title if long enough
title="$common_prefix"
log_verbose "Filename pattern detected - Title: '$title'" >&2
fi
fi
echo "$title|$author"
}
extract_metadata() {
local audio_files=("$@")
local first_audio="${audio_files[0]}"
local book_name="$(basename "$(dirname "$first_audio")")"
local book_dir="$(dirname "$first_audio")"
log_verbose "Extracting metadata from: $(basename "$first_audio")" >&2
# Extract audio metadata
local audio_title=$(ffprobe -v quiet -show_entries format_tags=title -of default=noprint_wrappers=1:nokey=1 "$first_audio" 2>/dev/null || echo "")
local audio_artist=$(ffprobe -v quiet -show_entries format_tags=artist -of default=noprint_wrappers=1:nokey=1 "$first_audio" 2>/dev/null || echo "")
local audio_album=$(ffprobe -v quiet -show_entries format_tags=album -of default=noprint_wrappers=1:nokey=1 "$first_audio" 2>/dev/null || echo "")
local date=$(ffprobe -v quiet -show_entries format_tags=date -of default=noprint_wrappers=1:nokey=1 "$first_audio" 2>/dev/null || echo "")
# Analyze filename patterns for multi-file books
local filename_patterns=$(analyze_filename_patterns "${audio_files[@]}")
IFS='|' read -r pattern_title pattern_artist <<< "$filename_patterns"
# Manual overrides take highest priority
local final_title="$OVERRIDE_TITLE"
local final_artist="$OVERRIDE_AUTHOR"
# If no manual override for title, use audio metadata and clean it
if [[ -z "$final_title" ]]; then
if [[ -n "$audio_title" ]] && ! echo "$audio_title" | grep -q "Chapter\|Part\|CD"; then
# Clean track numbers from audio title
local clean_audio_title="$audio_title"
if echo "$audio_title" | grep -q "^[0-9]\{2,3\}[[:space:]]"; then
clean_audio_title=$(echo "$audio_title" | sed -E 's/^[0-9]{2,3}[[:space:]]*//')
log_verbose "Cleaned audio title: '$audio_title' -> '$clean_audio_title'" >&2
fi
if [[ ${#clean_audio_title} -gt 10 ]]; then
final_title="$clean_audio_title"
log_verbose "Using cleaned audio metadata title: $final_title" >&2
elif [[ -n "$pattern_title" ]]; then
final_title="$pattern_title"
log_verbose "Using filename pattern title: $final_title" >&2
else
final_title="$book_name"
log_verbose "Using folder name as title: $final_title" >&2
fi
elif [[ -n "$pattern_title" ]]; then
final_title="$pattern_title"
log_verbose "Using filename pattern title: $final_title" >&2
else
final_title="$book_name"
log_verbose "Using folder name as title: $final_title" >&2
fi
else
log_verbose "Using override title: $final_title" >&2
fi
# If no manual override for artist, use audio metadata, then filename patterns
if [[ -z "$final_artist" ]]; then
if [[ -n "$audio_artist" ]]; then
final_artist="$audio_artist"
log_verbose "Using audio metadata artist: $final_artist" >&2
elif [[ -n "$pattern_artist" ]]; then
final_artist="$pattern_artist"
log_verbose "Using filename pattern artist: $final_artist" >&2
fi
else
log_verbose "Using override author: $final_artist" >&2
fi
# Album
local final_album=""
if [[ -n "$audio_album" ]] && ! echo "$audio_album" | grep -q "Chapter\|Part\|CD"; then
final_album="$audio_album"
else
final_album="$final_title"
log_verbose "Using title as album: $final_album" >&2
fi
echo "$final_title|$final_artist|$final_album|$date"
}
detect_bitrate() {
local first_audio="$1"
local original_bitrate=$(ffprobe -v quiet -show_entries format=bit_rate -of default=noprint_wrappers=1:nokey=1 "$first_audio" 2>/dev/null)
if [[ -n "$original_bitrate" && "$original_bitrate" != "N/A" ]]; then
local kbps=$((original_bitrate / 1000))
if [[ $kbps -ge 256 ]]; then
echo "256k"
elif [[ $kbps -ge 192 ]]; then
echo "192k"
elif [[ $kbps -ge 128 ]]; then
echo "128k"
elif [[ $kbps -ge 96 ]]; then
echo "96k"
else
echo "64k"
fi
else
echo "128k"
fi
}
format_duration() {
local seconds=$1
local hours=$((seconds / 3600))
local minutes=$(((seconds % 3600) / 60))
local secs=$((seconds % 60))
if [[ $hours -gt 0 ]]; then
printf "%dh %dm" $hours $minutes
elif [[ $minutes -gt 0 ]]; then
printf "%dm %ds" $minutes $secs
else
printf "%ds" $secs
fi
}
show_progress() {
local current_time=$1
local total_duration=$2
if [[ $total_duration -eq 0 ]]; then
return
fi
local percent=$((current_time * 100 / total_duration))
if [[ $percent -gt 100 ]]; then
percent=100
fi
local bar_width=40
local filled=$((percent * bar_width / 100))
local empty=$((bar_width - filled))
local bar=""
for ((i=0; i<filled; i++)); do
bar+="█"
done
for ((i=0; i<empty; i++)); do
bar+="░"
done
local current_formatted=$(format_duration $current_time)
local total_formatted=$(format_duration $total_duration)
printf "\r[%s] %3d%% (%s / %s)" "$bar" "$percent" "$current_formatted" "$total_formatted"
}
run_ffmpeg_with_progress() {
local total_duration=$1
shift
local ffmpeg_args=("$@")
if [[ "$QUIET" == "true" ]]; then
ffmpeg "${ffmpeg_args[@]}" 2>/dev/null
return
fi
# Create a temp file for ffmpeg progress
local progress_file=$(mktemp)
# Cleanup function
cleanup_ffmpeg() {
if [[ -n "$ffmpeg_pid" ]]; then
kill $ffmpeg_pid 2>/dev/null
wait $ffmpeg_pid 2>/dev/null
fi
rm -f "$progress_file"
echo # New line after progress bar
exit 1
}
# Set up signal traps
trap cleanup_ffmpeg INT TERM
# Run ffmpeg in background with progress output
if [[ "$VERBOSE" == "true" ]]; then
ffmpeg -y "${ffmpeg_args[@]}" &
else
ffmpeg -y -progress "$progress_file" "${ffmpeg_args[@]}" 2>"${progress_file}.err" &
fi
local ffmpeg_pid=$!
# Monitor progress (only in non-verbose mode)
if [[ "$VERBOSE" != "true" ]]; then
local current_time=0
while kill -0 $ffmpeg_pid 2>/dev/null; do
if [[ -f "$progress_file" ]]; then
# Get the latest time from progress file
local latest_time=$(grep "out_time_ms=" "$progress_file" 2>/dev/null | tail -1 | cut -d'=' -f2)
if [[ -n "$latest_time" && "$latest_time" != "N/A" ]]; then
current_time=$((latest_time / 1000000)) # Convert microseconds to seconds
show_progress $current_time $total_duration
fi
fi
sleep 0.1 # Faster polling for quick conversions
done
# Show final progress
show_progress $total_duration $total_duration
echo # New line after progress bar
fi
# Wait for ffmpeg to complete and get exit status
wait $ffmpeg_pid
local exit_status=$?
# Clean up
trap - INT TERM
rm -f "$progress_file" "${progress_file}.err"
# Check for errors if conversion failed
if [[ $exit_status -ne 0 && -f "${progress_file}.err" ]]; then
echo "\nConversion failed. Error details:" >&2
cat "${progress_file}.err" >&2
fi
return $exit_status
}
create_chapter_metadata() {
local audio_files=("$@")
local chapter_file=$(mktemp)
local start_time=0
local chapter_num=1
local total_chapters=${#audio_files[@]}
local total_duration=0
if [[ "$QUIET" != "true" ]]; then
echo "Creating chapter metadata (${total_chapters} chapters)..." >&2
fi
echo ";FFMETADATA1" > "$chapter_file"
for file in "${audio_files[@]}"; do
if [[ "$QUIET" != "true" ]]; then
echo "Processing chapter $chapter_num/$total_chapters: $(basename "$file")..." >&2
fi
local duration=$(ffprobe -v quiet -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file" 2>/dev/null)
duration=$(printf "%.0f" "$duration")
total_duration=$((total_duration + duration))
local filename=$(basename "$file")
filename=${filename%.*} # Remove extension
local chapter_title="Chapter $chapter_num"
if echo "$filename" | grep -q "CD[0-9]"; then
chapter_title="$filename"
elif echo "$filename" | grep -q "Chapter.*[0-9]"; then
chapter_title="$filename"
elif echo "$filename" | grep -q "Part.*[0-9]"; then
chapter_title="$filename"
elif echo "$filename" | grep -q "^[0-9]"; then
chapter_title="$filename"
fi
echo "" >> "$chapter_file"
echo "[CHAPTER]" >> "$chapter_file"
echo "TIMEBASE=1/1000" >> "$chapter_file"
echo "START=$((start_time * 1000))" >> "$chapter_file"
echo "END=$(((start_time + duration) * 1000))" >> "$chapter_file"
echo "title=$chapter_title" >> "$chapter_file"
start_time=$((start_time + duration))
chapter_num=$((chapter_num + 1))
done
log_verbose "Chapter metadata file: $chapter_file" >&2
echo "$chapter_file|$total_duration"
}
process_book() {
local book_dir="$1"
local bitrate="$2"
local chapter_time="$3"
if [[ ! -d "$book_dir" ]]; then
echo "Error: Directory not found: $book_dir"
return 1
fi
local audio_files=()
while IFS= read -r -d '' file; do
audio_files+=("$file")
done < <(find "$book_dir" \( -name "*.mp3" -o -name "*.aac" -o -name "*.m4a" -o -name "*.mp4" -o -name "*.flac" -o -name "*.ogg" -o -name "*.wav" \) -type f -print0 | sort -z)
if [[ ${#audio_files[@]} -eq 0 ]]; then
echo "No audio files found in: $book_dir"
return 1
fi
local book_name=$(basename "$book_dir")
local output_file=$(get_output_path "$book_dir" "$book_name")
local output_dir=$(dirname "$output_file")
if [[ ! -d "$output_dir" ]]; then
log_verbose "Creating output directory: $output_dir"
mkdir -p "$output_dir"
fi
log_info "Processing: $book_name (${#audio_files[@]} files)"
local metadata=$(extract_metadata "${audio_files[@]}")
IFS='|' read -r title artist album date <<< "$metadata"
if [[ "$bitrate" == "copy" ]]; then
bitrate=$(detect_bitrate "${audio_files[0]}")
log_info "Detected bitrate: $bitrate"
fi
# Create chapter metadata FIRST
local chapter_file
local chapter_result=$(create_chapter_metadata "${audio_files[@]}")
IFS='|' read -r chapter_file total_duration <<< "$chapter_result"
local metadata_args=()
metadata_args+=(-metadata "title=$title")
[[ -n "$artist" ]] && metadata_args+=(-metadata "artist=$artist")
metadata_args+=(-metadata "album=$album")
[[ -n "$date" ]] && metadata_args+=(-metadata "date=$date")
metadata_args+=(-metadata "genre=Audiobook")
metadata_args+=(-metadata "media_type=2")
log_verbose "Metadata: Title='$title', Artist='$artist', Album='$album'"
local duration_formatted=$(format_duration $total_duration)
log_info "Converting to M4B with chapters (${#audio_files[@]} files, $duration_formatted)..."
# Check if output file exists and prompt user
if [[ -f "$output_file" ]]; then
echo "File '$output_file' already exists."
read -p "Overwrite? [y/N] " -n 1 -r
echo # New line after response
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Conversion cancelled."
rm -f "$chapter_file"
return 0
fi
fi
# Fast mode for single files - transcode audio but copy video
if [[ "$FAST_MODE" == "true" || (${#audio_files[@]} -eq 1 && "$FORCE_CONCAT" != "true") ]]; then
log_verbose "Using fast single-file conversion"
if [[ "$VERBOSE" == "true" ]]; then
run_ffmpeg_with_progress $total_duration -v info -stats -i "${audio_files[0]}" \
-i "$chapter_file" \
-map 0:a -map 0:v? -map_metadata 1 \
-c:a aac_at -b:a "$bitrate" \
-c:v copy -disposition:v:0 attached_pic \
-movflags +faststart \
"${metadata_args[@]}" \
"$output_file"
else
run_ffmpeg_with_progress $total_duration -v error -i "${audio_files[0]}" \
-i "$chapter_file" \
-map 0:a -map 0:v? -map_metadata 1 \
-c:a aac_at -b:a "$bitrate" \
-c:v copy -disposition:v:0 attached_pic \
-movflags +faststart \
"${metadata_args[@]}" \
"$output_file"
fi
else
# Multi-file conversion - extract cover art, convert audio-only, then add cover back
local temp_list=$(mktemp)
local cover_file=""
# Extract cover art from first file if it exists
if ffprobe -v quiet -select_streams v:0 -show_entries stream=index "${audio_files[0]}" 2>/dev/null | grep -q "index"; then
cover_file=$(mktemp -t cover.XXXXXX).jpg
ffmpeg -v quiet -i "${audio_files[0]}" -an -vcodec copy "$cover_file" 2>/dev/null || cover_file=""
log_verbose "Extracted cover art to temp file"
fi
for file in "${audio_files[@]}"; do
echo "file '$file'" >> "$temp_list"
done
if [[ "$VERBOSE" == "true" ]]; then
run_ffmpeg_with_progress $total_duration -v info -stats -f concat -safe 0 -i "$temp_list" \
-i "$chapter_file" \
-map 0:a -map_metadata 1 \
-c:a aac_at -b:a "$bitrate" \
-movflags +faststart \
"${metadata_args[@]}" \
"$output_file"
else
run_ffmpeg_with_progress $total_duration -v error -f concat -safe 0 -i "$temp_list" \
-i "$chapter_file" \
-map 0:a -map_metadata 1 \
-c:a aac_at -b:a "$bitrate" \
-movflags +faststart \
"${metadata_args[@]}" \
"$output_file"
fi
# Add cover art back if we extracted one
if [[ -n "$cover_file" && -f "$cover_file" ]]; then
local temp_output=$(mktemp -t output.XXXXXX).m4b
mv "$output_file" "$temp_output"
if [[ "$VERBOSE" == "true" ]]; then
ffmpeg -v info -i "$temp_output" -i "$cover_file" \
-map 0:a -map 1:0 \
-c:a copy -c:v copy -disposition:v:0 attached_pic \
"$output_file"
else
ffmpeg -v quiet -i "$temp_output" -i "$cover_file" \
-map 0:a -map 1:0 \
-c:a copy -c:v copy -disposition:v:0 attached_pic \
"$output_file" 2>/dev/null
fi
rm "$temp_output" "$cover_file"
log_verbose "Added cover art back to final file"
fi
rm "$temp_list"
fi
rm "$chapter_file"
log_info "Created: $output_file"
if [[ "$QUIET" != "true" ]]; then
echo "Title: $title"
[[ -n "$artist" ]] && echo "Artist: $artist"
echo "Chapters: ${#audio_files[@]}"
echo ""
fi
}
# Parse arguments
BITRATE="$DEFAULT_BITRATE"
CHAPTER_TIME="$DEFAULT_CHAPTER_TIME"
TARGET_PATH=""
while [[ $# -gt 0 ]]; do
case $1 in
-b|--bitrate)
BITRATE="$2"
shift 2
;;
-c|--chapter-time)
CHAPTER_TIME="$2"
shift 2
;;
-o|--output-dir)
OUTPUT_DIR="$2"
OUTPUT_TO_PARENT=false
shift 2
;;
-p|--parent)
OUTPUT_TO_PARENT=true
OUTPUT_DIR=""
shift
;;
-s|--same-dir)
OUTPUT_TO_PARENT=false
OUTPUT_DIR=""
shift
;;
-a|--author)
OVERRIDE_AUTHOR="$2"
shift 2
;;
-t|--title)
OVERRIDE_TITLE="$2"
shift 2
;;
--no-lookup)
NO_LOOKUP=true
shift
;;
--fast)
FAST_MODE=true
shift
;;
--force-concat)
FORCE_CONCAT=true
shift
;;
-v|--verbose)
VERBOSE=true
shift
;;
-q|--quiet)
QUIET=true
shift
;;
-h|--help)
show_help
exit 0
;;
-*)
echo "Unknown option: $1"
show_help
exit 1
;;
*)
TARGET_PATH="$1"
shift
;;
esac
done
if [[ -z "$TARGET_PATH" ]]; then
echo "Error: Please specify a path"
show_help
exit 1
fi
check_dependencies
TARGET_PATH=$(realpath "$TARGET_PATH")
if [[ -n "$OUTPUT_DIR" ]]; then
OUTPUT_DIR=$(realpath "$OUTPUT_DIR")
if [[ ! -d "$OUTPUT_DIR" ]]; then
log_info "Creating output directory: $OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
fi
fi
if [[ -d "$TARGET_PATH" ]]; then
if find "$TARGET_PATH" -maxdepth 1 \( -name "*.mp3" -o -name "*.aac" -o -name "*.m4a" -o -name "*.mp4" -o -name "*.flac" -o -name "*.ogg" -o -name "*.wav" \) -type f | head -1 | grep -q .; then
process_book "$TARGET_PATH" "$BITRATE" "$CHAPTER_TIME"
else
for book_dir in "$TARGET_PATH"/*/; do
if [[ -d "$book_dir" ]]; then
process_book "$book_dir" "$BITRATE" "$CHAPTER_TIME"
fi
done
fi
else
echo "Error: Not a directory: $TARGET_PATH"
exit 1
fi
echo "All done!"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment