From: Steven Baltakatei Sandoval Date: Wed, 19 Jan 2022 04:51:05 +0000 (+0000) Subject: feat(us/bk-copy-rand-music.sh):Add script X-Git-Tag: 0.5.0~23 X-Git-Url: https://zdv2.bktei.com/gitweb/BK-2020-03.git/commitdiff_plain/a9a36cc48e1b18d63c1b1f240c9347191ec8d67f feat(us/bk-copy-rand-music.sh):Add script --- diff --git a/unitproc/bktemp-checkIsInArray b/unitproc/bktemp-checkIsInArray new file mode 100644 index 0000000..15d2fab --- /dev/null +++ b/unitproc/bktemp-checkIsInArray @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +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 +checkIsInArray() { + # Desc: Checks if input arg is element in array + # Usage: checkIsInArray arg1 arg2 + # Version: 0.0.1 + # Input: arg1: test string + # arg2: array + # Output: exit code 0 if test string is in array; 1 otherwise + # Example: checkIsInArray "foo" "${myArray[@]}" + # Ref/Attrib: [1] How do I check if variable is an array? https://stackoverflow.com/a/27254437 + # [2] How to pass an array as function argument? https://askubuntu.com/a/674347 + local return_state input arg1 arg2 string_test + local -a array_test + input=("$@") # See [2] + arg1="${input[0]}"; + arg2=("${input[@]:1}"); + #yell "DEBUG:input:${input[@]}"; + #yell "DEBUG:arg1:${arg1[@]}"; + #yell "DEBUG:arg2:${arg2[@]}"; + + string_test="$arg1"; + array_test=("${arg2[@]}"); + + #yell "DEBUG:string_test:$string_test"; + #yell "DEBUG:$(declare -p array_test)"; + for element in "${array_test[@]}"; do + #yell "DEBUG:element:$element"; + if [[ "$element" =~ ^"$string_test" ]]; then + return_state="true"; + continue; + fi; + done; + + # Report exit code + if [[ $return_state == "true" ]]; then + return 0; + else + return 1; + fi; +} # Check if string is element in array + +# Sample test code +my_array=("jan" "feb" "mar" "apr"); +yell "Array contains:${my_array[@]}"; +test_string="feb"; +yell "Checking to see if $test_string is in array..."; +if checkIsInArray "$test_string" "${my_array[@]}"; then + yell "\"$test_string\" is in array"; +else + yell "\"$test_string\" is not in array"; +fi; +yell ""; # debug + +sleep 1; + +my_array=("jan" "feb" "mar" "apr"); +yell "Array contains:${my_array[@]}"; +test_string="oct"; +yell "Checking to see if $test_string is in array..."; +if checkIsInArray "$test_string" "${my_array[@]}"; then + yell "\"$test_string\" is in array"; +else + yell "\"$test_string\" is not in array"; +fi; +yell ""; # debug + +sleep 1; + +my_array=("jan" "feb" "mar" "apr"); +yell "Array contains:${my_array[@]}"; +test_string="feb mar"; +yell "Checking to see if $test_string is in array..."; +if checkIsInArray "$test_string" "${my_array[@]}"; then + yell "\"$test_string\" is in array"; +else + yell "\"$test_string\" is not in array"; +fi; +yell ""; # debug diff --git a/unitproc/bktemp-check_parsable_audio_ffprobe b/unitproc/bktemp-check_parsable_audio_ffprobe new file mode 100644 index 0000000..9230a3f --- /dev/null +++ b/unitproc/bktemp-check_parsable_audio_ffprobe @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +check_parsable_audio_ffprobe() { + # Desc: Checks if ffprobe returns valid audio codec name for file + # Usage: check_parsable_audio_ffprobe [path FILE] + # Version: 0.0.1 + # Input: arg1: file path + # Output: exit code 0 if returns valid codec name; 1 otherwise + # Depends: ffprobe, die() + local file_in ffprobe_out + + if [[ $# -ne 1 ]]; then die "ERROR:Invalid number of args:$#"; fi; + + file_in="$1"; + + # Check if ffprobe detects an audio stream + 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="true"; + else + return_state="false"; + fi; + + # Fail if ffprobe returns no result + ffprobe_out="$(ffprobe -v error -select_streams a -show_entries stream=codec_name -of default=nokey=1:noprint_wrappers=1 "$file_in")"; + if [[ -z $ffprobe_out ]]; then + return_state="false"; + fi; + + # Report exit code + if [[ $return_state = "true" ]]; then + return 0; + else + return 1; + fi; +} # Checks if file has valid codec name using ffprobe + +# Author: Steven Baltakatei Sandoval +# License: GPLv3+ diff --git a/unitproc/bktemp-get_audio_format b/unitproc/bktemp-get_audio_format new file mode 100644 index 0000000..78d9cf6 --- /dev/null +++ b/unitproc/bktemp-get_audio_format @@ -0,0 +1,48 @@ +#!/usr/bin/env bash + +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 + +# Author: Steven Baltakatei Sandoval +# License: GPLv3+ \ No newline at end of file diff --git a/unitproc/bktemp-get_media_length b/unitproc/bktemp-get_media_length new file mode 100644 index 0000000..76c8f3d --- /dev/null +++ b/unitproc/bktemp-get_media_length @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +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 + # Ref/Attrib: [1] How to get video duration in seconds? https://superuser.com/a/945604 + 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 + +# Author: Steven Baltakatei Sandoval +# License: GPLv3+ \ No newline at end of file diff --git a/user/bk-copy-rand-music.sh b/user/bk-copy-rand-music.sh new file mode 100644 index 0000000..c326e41 --- /dev/null +++ b/user/bk-copy-rand-music.sh @@ -0,0 +1,478 @@ +#!/usr/bin/env bash +# Desc: Copies random music +# Usage: bk-copy-rand-music.sh [dir SOURCE] [dir DEST] [int DURATION] + +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 +declare -a music_codecs # Array for storing valid codec names (e.g. "aac" "mp3") + +# Adjustable parameters +music_codecs=("vorbis" "aac" "mp3" "flac" "opus"); # whitelist of valid codec_names ffprobe might return +max_loops=1000000; # max number of files to test whether are audio or not + +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 1.0.0 + # Input: associative arrays: appRollCall, fileRollCall, dirRollCall + # Output: stderr: messages indicating missing apps, file, or dirs + # Output: returns exit code 0 if nothing missing; 1 otherwise + # 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== + #==BEGIN Determine function return code=== + if [ "$appMissing" == "true" ] || [ "$fileMissing" == "true" ] || [ "$dirMissing" == "true" ]; then + return 1; + else + return 0; + fi + #==END Determine function return code=== +} # 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' + + DESCRIPTION: + This script may be used to copy a random selection of files from + SOURCE to DEST. + + USAGE: + bk-copy-rand-music [dir SOURCE] [dir DEST] [int DURATION] + + EXAMPLE: + bk-copy-rand-music ~/Music /tmp/music-sample 3600 + + DEPENDENCIES: + ffprobe + GNU Coreutils 8.30 +EOF +} # Display information on how to use this script. +check_parsable_audio_ffprobe() { + # Desc: Checks if ffprobe returns valid audio codec name for file + # Usage: check_parsable_audio_ffprobe [path FILE] + # Version: 0.0.1 + # Input: arg1: file path + # Output: exit code 0 if returns valid codec name; 1 otherwise + # Depends: ffprobe, die() + local file_in ffprobe_out + + if [[ $# -ne 1 ]]; then die "ERROR:Invalid number of args:$#"; fi; + + file_in="$1"; + + # Check if ffprobe detects an audio stream + 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="true"; + else + return_state="false"; + fi; + + # Fail if ffprobe returns no result + ffprobe_out="$(ffprobe -v error -select_streams a -show_entries stream=codec_name -of default=nokey=1:noprint_wrappers=1 "$file_in")"; + if [[ -z $ffprobe_out ]]; then + return_state="false"; + fi; + + # Report exit code + if [[ $return_state = "true" ]]; then + return 0; + else + return 1; + fi; +} # Checks if file has valid codec name using ffprobe +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 +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 + # Ref/Attrib: [1] How to get video duration in seconds? https://superuser.com/a/945604 + 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 +checkInt() { + # Desc: Checks if arg is integer + # Usage: checkInt arg + # Input: arg: integer + # Output: - return code 0 (if arg is integer) + # - return code 1 (if arg is not integer) + # Example: if ! checkInt $arg; then echo "not int"; fi; + # Version: 0.0.1 + local returnState + + #===Process Arg=== + if [[ $# -ne 1 ]]; then + die "ERROR:Invalid number of arguments:$#"; + fi; + + RETEST1='^[0-9]+$'; # Regular Expression to test + if [[ ! $1 =~ $RETEST1 ]] ; then + returnState="false"; + else + returnState="true"; + fi; + + #===Determine function return code=== + if [ "$returnState" = "true" ]; then + return 0; + else + return 1; + fi; +} # Checks if arg is integer +checkIsInArray() { + # Desc: Checks if input arg is element in array + # Usage: checkIsInArray arg1 arg2 + # Version: 0.0.1 + # Input: arg1: test string + # arg2: array + # Output: exit code 0 if test string is in array; 1 otherwise + # Example: checkIsInArray "foo" "${myArray[@]}" + # Ref/Attrib: [1] How do I check if variable is an array? https://stackoverflow.com/a/27254437 + # [2] How to pass an array as function argument? https://askubuntu.com/a/674347 + local return_state input arg1 string_test + declare -a arg2 array_test + input=("$@") # See [2] + arg1="${input[0]}"; + arg2=("${input[@]:1}"); + #yell "DEBUG:input:${input[@]}"; + #yell "DEBUG:arg1:${arg1[@]}"; + #yell "DEBUG:arg2:${arg2[@]}"; + + string_test="$arg1"; + array_test=("${arg2[@]}"); + + #yell "DEBUG:string_test:$string_test"; + #yell "DEBUG:$(declare -p array_test)"; + for element in "${array_test[@]}"; do + #yell "DEBUG:element:$element"; + if [[ "$element" =~ ^"$string_test" ]]; then + return_state="true"; + continue; + fi; + done; + + # Report exit code + if [[ $return_state == "true" ]]; then + return 0; + else + return 1; + fi; +} # Check if string is element in array +main() { + # Desc: Main program + # Input: arg1: path to source tree + # arg2: path to destination tree + # arg3: cumulative duration (seconds) of audio files in destination tree + # assoc arrays: appRollCall, fileRollCall, dirRollCall + # Output: [none] + # Depends: yell(), checkdir() 0.1.2, displayMissing() 1.0.0, GNU Coreutils 8.30 (shuf) + local arg1 arg2 arg3 dur_dest dir_source dir_dest list_all + declare -a list_files # array for files to be considered + declare -A list_copy # assoc array for files to be copied (key=path; value=duration) + + # Parse args + arg1="$1"; + arg2="$2"; + arg3="$3"; + if [[ $# -ne 3 ]]; then showUsage; die "ERROR:Invalid number of args."; fi; + + ## Check duration + if checkInt "$arg3"; then + dur_dest="$arg3"; + else + yell "ERROR:Duration (seconds) not an int:$arg3" + fi; + + ## Check directories + if checkdir "$arg1" "$arg2"; then + dir_source="$arg1"; + dir_dest="$arg2"; + else + yell "ERROR:Directory error"; + fi; + + ## Check apps + checkapp ffprobe; + + if ! displayMissing; then + showUsage; + die "ERROR:Check missing resources."; + fi; + + yell "STATUS:Working..."; + + # Generate file path list + list_all="$(find -L "$dir_source")"; + #yell "DEBUG:list_files_rel:$list_files_rel"; + + # Prune list_all of non-files and save as array list_files + while read -r line; do + #yell "DEBUG:line:$line"; + if ! [[ -f $line ]]; then + #yell "DEBUG:Not a file:$line"; + #yell ""; # debug + continue; + fi; + list_files+=("$line"); + done < <(echo "$list_all"); + + # Randomly test and add elements of list_files array to list_copy + dur=0; # Initialize duration + n=0; # Initialize loop counter + ## Get element count of list_files array + list_files_count="${#list_files[@]}"; + while [[ $dur -le $dur_dest ]]; do + #yell "DEBUG:list_copy building loop:$n"; + ### Select random element of list_files array + list_files_index="$(shuf -i 1-"$list_files_count" -n1)"; + list_files_index="$((list_files_index - 1))"; # bash arrays are zero-indexed + path_candfile="${list_files[$list_files_index]}"; # path of candidate file + + ### Check if has valid codec + if ! check_parsable_audio_ffprobe "$path_candfile"; then continue; fi; # reject + + ### Check if desired codec + file_format="$(get_audio_format "$path_candfile")"; + if ! checkIsInArray "$file_format" "${music_codecs[@]}"; then continue; fi; # reject + + ### Check and save duration + dur_cand="$(get_media_length "$path_candfile")"; + dur_cand="${dur_cand%%.*}"; # convert float to int + if ! checkInt "$dur_cand"; then continue; fi; # reject + + ### Add/update candfile to list_copy assoc. array (key=path; value=duration) + #yell "DEBUG:Adding $path_candfile"; + list_copy["$path_candfile"]="$dur_cand"; + + ### Update total duration $dur by summing all list_copy assoc. array values + dur=0; + for value in "${list_copy[@]}"; do + dur="$((dur + value))"; + done; + #yell "DEBUG:dur:$dur"; + + ### Sanity check + ((n++)); + if [[ $n -gt $max_loops ]]; then die "ERROR:Too many loops:$n"; fi; + done; + + # Copy files in list_copy to dir_dest; + for key in "${!list_copy[@]}"; do + value="${list_copy[$key]}"; + ## Get basename of path + file_basename="$(basename "$key")"; + + ## Get 16-character b2sum fingerprint (for different files that share basename) + fingerprint="$(b2sum -l64 "$key" | cut -d' ' -f1)"; + + ## Form output path + path_output="$dir_dest"/"$fingerprint".."$file_basename"; + + ## Copy + try cp "$key" "$path_output" && yell "NOTICE:Copied ($value seconds): $key "; + #yell "DEBUG:Copied $file_basename to $dur_dest."; + + unset file_basename path_output + done; + + # Report total duration + yell "NOTICE:Total duration (seconds):$dur"; + +} # Main program + +main "$@"; + +# Author: Steven Baltakatei Sandoval +# License: GPLv3+