#!/bin/bash
# Desc: Converts a directory of mp3s into a single m4b file with chapters
# Usage: mp3s_to_m4b.sh [DIR in] [DIR out] [BITRATE]
# Example: mp3s_to_m4b.sh ./dir_source ./dir_output 64k
# Depends: GNU Coreutils 8.32 (date), ffmpeg, ffprobe
# Ref/Attrib: [1] FFmpeg Formats Documentation https://ffmpeg.org/ffmpeg-formats.html#toc-Metadata-2
# Version: 0.0.1

# plumbing
aac_bitrate="$3"; # e.g. "64k"
script_rundate="$(date +%s)";
dir_tmp="/dev/shm";
dir_in="$(readlink -f "$1")";
dir_out="$(readlink -f "$2")";
file_flist="$dir_tmp"/"$script_rundate"..flist.txt;
file_out_m4b="$dir_out"/output.m4b;
file_albumart="$dir_out"/albumart.png;
file_chapters="$dir_tmp"/"$script_rundate"..chapters.txt;

yell() { echo "$0: $*" >&2; } # print script path and all args to stderr
die() { yell "$*"; exit 111; } # same as yell() but non-zero exit status
must() { "$@" || die "cannot $*"; } # runs args as command, reports args if command fails
show_usage() {
    # Desc: Display script usage information
    # Usage: showUsage
    # Version 0.0.2
    # Input: none
    # Output: stdout
    # Depends: GNU-coreutils 8.30 (cat)
    cat <<'EOF'
    USAGE:
        mp3s_to_m4b.sh [DIR in] [DIR out] [BITRATE]

    EXAMPLE:
      mp3s_to_m4b.sh ./source_dir ./out_dir 64k
EOF
} # Display information on how to use this script.
check_depends() {
    if ! command -v ffmpeg; then show_usage; die "FATAL:Missing ffmpeg."; fi;
    if ! command -v ffprobe; then show_usage; die "FATAL:Missing ffprobe."; fi;
}; # check dependencies
check_plumbing() {
    if [[ $# -ne 3 ]]; then show_usage; die "FATAL:Invalid arg count:$#"; fi;
    if [[ ! -d "$dir_in" ]]; then show_usage; die "FATAL:Not a dir:$dir_in"; fi;
    if [[ ! -d "$dir_out" ]]; then mkdir -p "$2"; fi;
}; # check arguments
get_media_length() {
    # Use ffprobe to get media container length in seconds (float)
    # Usage: get_media_length arg1
    # Input:  arg1: path to file
    # Output: stdout: seconds (float)
    # Depends: ffprobe 4.1.8
    #          BK-2020-03: die()
    local file_in;
    file_in="$1";
    if [[ ! -f $file_in ]]; then
        die "ERROR:Not a file:$file_in";
    fi;
    ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_in";
} # Get media container length in seconds via stdout
build_filelist_and_chapters() {
    # Depends: var dir_tmp     temporary directory
    #          var dir_in      input dir
    #          var file_flist  path file list
    # Output: file: $file_flist     list of mp3 files for ffmpeg
    #         file: $file_chapters  chapters file for ffmpeg

    # Change directory to input dir
    pushd "$dir_in" || die "FATAL:Directory error:$(pwd)";

    local chapter_start=0;
    local chapter_end;
    local duration;


    # Initialize chapter ffmetadata file. See [1].
    {
        echo ";FFMETADATA1";
    } >> "$file_chapters";

    
    find "$dir_in" -type f -iname "*.mp3" | sort | while read -r line; do
        local filename="${line#./}";
        yell "$(printf "file '%s'\n" "$filename")";
        printf "file '%s'\n" "$filename" >> "$file_flist";

        # Get duration of the current file
        duration=$(get_media_length "$filename");
        chapter_end=$(echo "$chapter_start + $duration" | bc -l);

        # Write chapter info
        {
            echo "[CHAPTER]";
            echo "TIMEBASE=1/1000";
            echo "START=$(echo "scale=0; $chapter_start * 1000" | bc -l)";
            echo "END=$(echo "scale=0; $chapter_end * 1000" | bc -l)";
            echo "title=$(basename "$filename" .mp3)";
        } >> "$file_chapters";

        chapter_start=$chapter_end;
    done

    # Return to original dir
    popd || die "FATAL:Directory error:$(pwd)";
}; # build file list and chapters for ffmpeg
ffmpeg_convert() {
    # Depends: var dir_tmp
    #              dir_in
    #              dir_out
    # Input: file $file_flist     list of mp3 files for ffmpeg
    #        file $file_chapters  chapters file for ffmpeg

    # Change directory to input dir
    pushd "$dir_in" || die "FATAL:Directory error:$(pwd)";

    # Concatenate mp3 files into a single WAV file
    # Convert WAV to aac m4b file
    ffmpeg -nostdin -f concat -safe 0 -i "$file_flist" -c:a pcm_s24le -rf64 auto -f wav - | \
        ffmpeg -i - -i "$file_chapters" \
               -map_metadata 1 -map_chapters 1 \
               -map 0 -c:a aac -b:a "$aac_bitrate" \
               "$file_out_m4b";

    # Return to original dir
    popd || die "FATAL:Directory error:$(pwd)";
}; # convert mp3s to aac m4b via ffmpeg
save_albumart() {
    local file
    file="$(find "$dir_in" -type f -iname "*.mp3" | sort | head -n1)";
    file="$(readlink -f "$file")";
    ffmpeg -nostdin -i "$file" -an -vcodec copy "$file_albumart";
}; # save album art from an mp3 to output dir
main() {
    check_depends && yell "DEBUG:check_depends OK";
    check_plumbing "$@" && yell "DEBUG:check_plumbing OK";
    build_filelist_and_chapters "$@" && yell "DEBUG:build_filelist_and_chapters OK";
    ffmpeg_convert "$@" && yell "DEBUG:ffmpeg_convert OK";
    save_albumart "$@" && yell "DEBUG:save_albumart OK";
}; # main program

main "$@";

# Author: Steven Baltakatei Sandoval
# License: GPLv3+
