#!/bin/bash
# Desc: Extracts audio from video files
# Usage: bk_export_audio.sh [input_dir] ([output_dir])
# Version: 0.1.1
# Depends: bash 5.1.16, GNU Coreutils (8.32)

# Plumbing
max_jobs="$(nproc --all)"; # max parallel audio conversion jobs
declare -Ag appRollCall # Associative array for storing app status
declare -Ag fileRollCall # Associative array for storing file status
declare -Ag dirRollCall # Associative array for storing dir status

yell() { echo "$0: $*" >&2; } # print script path and all args to stderr
die() { yell "$*"; exit 111; } # same as yell() but non-zero exit status
try() { "$@" || die "cannot $*"; } # runs args as command, reports args if command fails
checkapp() {
    # Desc: If arg is a command, save result in assoc array 'appRollCall'
    # Usage: checkapp arg1 arg2 arg3 ...
    # Version: 0.1.1
    # Input: global assoc. array 'appRollCall'
    # Output: adds/updates key(value) to global assoc array 'appRollCall'
    # Depends: bash 5.0.3
    local returnState    

    #===Process Args===
    for arg in "$@"; do
	if command -v "$arg" 1>/dev/null 2>&1; then # Check if arg is a valid command
	    appRollCall[$arg]="true";
	    if ! [ "$returnState" = "false" ]; then returnState="true"; fi;
	else
	    appRollCall[$arg]="false"; returnState="false";
	fi;
    done;

    #===Determine function return code===
    if [ "$returnState" = "true" ]; then
	return 0;
    else
	return 1;
    fi;
} # Check that app exists
checkfile() {
    # Desc: If arg is a file path, save result in assoc array 'fileRollCall'
    # Usage: checkfile arg1 arg2 arg3 ...
    # Version: 0.1.1
    # Input: global assoc. array 'fileRollCall'
    # Output: adds/updates key(value) to global assoc array 'fileRollCall';
    # Output: returns 0 if app found, 1 otherwise
    # Depends: bash 5.0.3
    local returnState

    #===Process Args===
    for arg in "$@"; do
	if [ -f "$arg" ]; then
	    fileRollCall["$arg"]="true";
	    if ! [ "$returnState" = "false" ]; then returnState="true"; fi;
	else
	    fileRollCall["$arg"]="false"; returnState="false";
	fi;
    done;
    
    #===Determine function return code===
    if [ "$returnState" = "true" ]; then
	return 0;
    else
	return 1;
    fi;
} # Check that file exists
checkdir() {
    # Desc: If arg is a dir path, save result in assoc array 'dirRollCall'
    # Usage: checkdir arg1 arg2 arg3 ...
    # Version 0.1.2
    # Input: global assoc. array 'dirRollCall'
    # Output: adds/updates key(value) to global assoc array 'dirRollCall';
    # Output: returns 0 if all args are dirs; 1 otherwise
    # Depends: Bash 5.0.3
    local returnState

    #===Process Args===
    for arg in "$@"; do
	if [ -z "$arg" ]; then
	    dirRollCall["(Unspecified Dirname(s))"]="false"; returnState="false";
	elif [ -d "$arg" ]; then
	    dirRollCall["$arg"]="true";
	    if ! [ "$returnState" = "false" ]; then returnState="true"; fi
	else
	    dirRollCall["$arg"]="false"; returnState="false";
	fi
    done
    
    #===Determine function return code===
    if [ "$returnState" = "true" ]; then
	return 0;
    else
	return 1;
    fi
} # Check that dir exists
displayMissing() {
    # Desc: Displays missing apps, files, and dirs
    # Usage: displayMissing
    # Version 0.1.1
    # Input: associative arrays: appRollCall, fileRollCall, dirRollCall
    # Output: stderr: messages indicating missing apps, file, or dirs
    # Depends: bash 5, checkAppFileDir()
    local missingApps value appMissing missingFiles fileMissing
    local missingDirs dirMissing
    
    #==BEGIN Display errors==
    #===BEGIN Display Missing Apps===
    missingApps="Missing apps  :";
    #for key in "${!appRollCall[@]}"; do echo "DEBUG:$key => ${appRollCall[$key]}"; done
    for key in "${!appRollCall[@]}"; do
	value="${appRollCall[$key]}";
	if [ "$value" = "false" ]; then
	    #echo "DEBUG:Missing apps: $key => $value";
	    missingApps="$missingApps""$key ";
	    appMissing="true";
	fi;
    done;
    if [ "$appMissing" = "true" ]; then  # Only indicate if an app is missing.
	echo "$missingApps" 1>&2;
    fi;
    unset value;
    #===END Display Missing Apps===

    #===BEGIN Display Missing Files===
    missingFiles="Missing files:";
    #for key in "${!fileRollCall[@]}"; do echo "DEBUG:$key => ${fileRollCall[$key]}"; done
    for key in "${!fileRollCall[@]}"; do
	value="${fileRollCall[$key]}";
	if [ "$value" = "false" ]; then
	    #echo "DEBUG:Missing files: $key => $value";
	    missingFiles="$missingFiles""$key ";
	    fileMissing="true";
	fi;
    done;
    if [ "$fileMissing" = "true" ]; then  # Only indicate if an app is missing.
	echo "$missingFiles" 1>&2;
    fi;
    unset value;
    #===END Display Missing Files===

    #===BEGIN Display Missing Directories===
    missingDirs="Missing dirs:";
    #for key in "${!dirRollCall[@]}"; do echo "DEBUG:$key => ${dirRollCall[$key]}"; done
    for key in "${!dirRollCall[@]}"; do
	value="${dirRollCall[$key]}";
	if [ "$value" = "false" ]; then
	    #echo "DEBUG:Missing dirs: $key => $value";
	    missingDirs="$missingDirs""$key ";
	    dirMissing="true";
	fi;
    done;
    if [ "$dirMissing" = "true" ]; then  # Only indicate if an dir is missing.
	echo "$missingDirs" 1>&2;
    fi;
    unset value;
    #===END Display Missing Directories===

    #==END Display errors==
} # Display missing apps, files, dirs
showUsage() {
    # Desc: Display script usage information
    # Usage: showUsage
    # Version 0.0.1
    # Input: none
    # Output: stdout
    # Depends: GNU-coreutils 8.30 (cat)
    cat <<'EOF'
    USAGE:
        bk_export_audio.sh [DIR in] ([DIR out])

    EXAMPLE:
      bk_export_audio.sh ./videos/ ./exported_audio/
      bk_export_audio.sh ./videos/
EOF
} # Display information on how to use this script.
get_audio_format() {
    # Desc: Gets audio format of file as string
    # Usage: get_audio_format arg1
    # Depends: ffprobe
    # Version: 0.0.1
    # Input: arg1: input file path
    # Output: stdout (if valid audio format)
    #         exit code 0 if audio file; 1 otherwise
    # Example: get_audio_format myvideo.mp4
    #   Note: Would return "opus" if full ffprobe report had 'Audio: opus, 48000 Hz, stereo, fltp'
    # Note: Not tested with videos containing multiple video streams
    # Ref/Attrib: [1] https://stackoverflow.com/questions/5618363/is-there-a-way-to-use-ffmpeg-to-determine-the-encoding-of-a-file-before-transcod
    #             [2] https://stackoverflow.com/questions/44123532/how-to-find-out-the-file-extension-for-extracting-audio-tracks-with-ffmpeg-and-p#comment88464070_50723126
    local audio_format file_in;
    local return_state;
    file_in="$1";

    # Return error exit code if not audio file
    ## Return error if ffprobe itself exited on error
    if ! ffprobe -v error -select_streams a -show_entries stream=codec_name -of default=nokey=1:noprint_wrappers=1 "$file_in" 1>/dev/null 2>&1; then
	return_state="false";
    fi;
    
    # Get audio format
    audio_format="$(ffprobe -v error -select_streams a -show_entries stream=codec_name -of default=nokey=1:noprint_wrappers=1 "$file_in")"; # see [1]

    ## Return error if audio format is incorrectly formatted (e.g. reject if contains spaces)
    pattern="^[[:alnum:]]+$"; # alphanumeric string with no spaces
    if [[ $audio_format =~ $pattern ]]; then
	return_state="true";
	# Report audio format
	echo "$audio_format";
    else
	return_state="false";
    fi;
    
    # Report exit code
    if [[ $return_state = "true" ]]; then
	return 0;
    else
	return 1;
    fi;
} # Get audio format as stdout
extract_audio_file() {
    # Desc: Use ffmpeg to creates audio file from input video file
    # Usage: extract_audio_file arg1 arg2 arg3
    # Depends: ffmpeg
    # Input: arg1: input video file path
    #        arg2: desired output file extension
    #        arg3: output dir path
    # Output: audio file at path [arg3]/[arg1].[arg2]
    local file_in file_in_ext dir_out file_in_basename path_out;
    file_in="$1";
    file_in_ext="$2";
    dir_out="$3";
    file_in_basename="$(basename "$file_in")";
    path_out="$dir_out"/"$file_in_basename"."$file_in_ext";

    # Skip if output file already exists.
    if [[ -f "$path_out" ]]; then return 1; fi;

    # Extract audio file
    ffmpeg -i "$file_in" -vn -acodec copy "$path_out";
} # Create audio file from video file
count_jobs() {
    # Desc: Count and return total number of jobs
    # Usage: count_jobs
    # Input: None.
    # Output: stdout   integer number of jobs
    # Depends: Bash 5.1.16
    # Example: while [[$(count_jobs) -gt 0]]; do echo "Working..."; sleep 1; done;
    # Version: 0.0.1
    
    local job_count;
    job_count="$(jobs -r | wc -l | tr -d ' ' )";
    #yell "DEBUG:job_count:$job_count";
    if [[ -z $job_count ]]; then job_count="0"; fi;
    echo "$job_count";
}; # Return number of background jobs
job() {
    # Input: arg1: file :   file to check and, if audio, export
    #        arg2: dir_out: output dir
    local file dir_out;
    file="$1";
    dir_out="$2";
    aud_format="$(get_audio_format "$file")"; # Get audio format as file extension string
    file_basename="$(basename "$file")"; # Get basename for debugging
    yell "DEBUG:file_basename:$file_basename";
    yell "DEBUG:aud_format:$aud_format";
    yell "DEBUG:";

    # Ignore files that return blank $aud_format
    if [[ -z $aud_format ]]; then
        yell "DEBUG:Not an audio file:$file";
        return 1;
    fi;

    # Convert video to audio
    extract_audio_file "$file" "$aud_format" "$dir_out";
}; # One file check and extraction job
main() {
    # Depends: yell(), die(), try()
    #     checkapp(), checkfile(), checkdir(), displayMissing(), showUsage(),
    #     extract_audio_file() get_audio_format()
    #   BK-2020-03: count_jobs v0.0.1
    script_pwd="$(pwd)";
    dir_in="$1";
    dir_out="$2";

    # Check argument count
    if [[ $# -lt 1 ]]; then
	showUsage;
	die "ERROR:Not enough arguments:$#";
    fi;

    # Check apps, dirs
    checkapp ffmpeg ffprobe date nproc;
    displayMissing;

    if ! checkdir "$dir_in"; then
	showUsage;
	displayMissing;
	die "ERROR:Missing input directory."
    fi;
    if ! checkdir "$dir_out"; then
	yell "NOTICE:Output directory not specified. Creating output directory in current working directory:$script_pwd";
	timestamp="$(date +%Y%m%dT%H%M%S%z)"; # iso-8601 without separators
	dir_out="$script_pwd"/"$timestamp"..output;
	try mkdir "$dir_out";
    fi;

    # Do work
    yell "DEBUG:dir_in:$dir_in":
    yell "DEBUG:dir_out:$dir_out";
    for file in "$dir_in"/*; do
        yell "DEBUG:count_jobs:$(count_jobs)";
        while [[ "$(count_jobs)" -ge $max_jobs ]]; do sleep 0.1; done; # limit jobs        
        job "$file" "$dir_out" &
    done;

    # Announce completion
    while [[ "$(count_jobs)" -gt 0 ]]; do sleep 1; done;
    printf "\n" 1>&2; yell "STATUS:Done.";
}; # main program

#export -f get_audio_format count_jobs extract_audio_file;
main "$@";

# Author: Steven Baltaktei Sandoval
# License: GPLv3+
