#!/usr/bin/env bash # # NAME # redoflacs - BASH commandline FLAC compressor, verifier, organizer, # analyzer, and retagger # # FILE INFORMATION # Homepage: https://github.com/sirjaren/redoflacs # Dependencies: BASH >= 4.0 # # DISTRIBUTION # The MIT License # # Copyright 2025 Project Authors # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation files # (the "Software"), to deal in the Software without restriction, # including without limitation the rights to use, copy, modify, merge, # publish, distribute, sublicense, and/or sell copies of the Software, # and to permit persons to whom the Software is furnished to do so, # subject to the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # ############################# FUNCTIONS ############################## # # Convenience functions to provide messages to the user with colored # prefixes to indicate message severity, eg: # _warn 'This is obsolete' # # Creates a messsage like (with a yellow asterisk): # * This is obsolete # _info() { printf ' %s %b\n' "$green*$reset" "$@"; } # Green _warn() { printf >&2 ' %s %b\n' "$yellow*$reset" "$@"; } # Yellow _error(){ printf >&2 ' %s %b\n' "$red*$reset" "$@"; } # Red # # Display help # _help() { cat << HELP_EOF Usage: redoflacs [operations] [options] [target] Operations: -c Compress FLAC files -C Compress FLAC files (even if COMPRESSION tag already exists) -t Test integrity of FLAC files -m Check for a valid MD5 signature in FLAC files' STREAMINFO block -a Use auCDtect to authenticate FLAC files -A Use auCDtect to authenticate FLAC files (create spectrogram) -l Use LAC to authenticate FLAC files -L Use LAC to authenticate FLAC files (create spectrogram) -e Extract all embedded artwork from FLAC files -p Remove metadata blocks from FLAC files (PADDING, PICTURE, etc) -g Apply ReplayGain to FLAC files -G Apply ReplayGain to FLAC files (even if ReplayGain tags exist) -r Retag FLAC files preserving user-defined tags Options: -j[N] Allow N jobs at once (default: all cores, or 2 if unknown) -J[N] Allow N jobs for compression >= FLAC 1.5.0 (default: cores/threads) -T[N] Allow N threads for compression >= FLAC 1.5.0 (default: 2) -n Disable color output -x Do not apply COMPRESSION tag when compressing FLAC files -o Generate a new configuration file and exit -v Print the version number of redoflacs and exit -h Print this message and exit See redoflacs(1) for more information. HELP_EOF } # # Display usage # _usage() { printf >&2 ' Usage: redoflacs [operations] [options] [target] ...\n' } # # Display a message that there may have been some issues that is worth # investigating for a given operation and where the log file to review # said issues can be found # _display_issues_exist() { # Operation-specific messaging case "$operation" in '_md5check') _error 'The MD5 Signature is unset for some FLAC files or there were' _error 'issues with some of the FLAC files, please check:' ;; '_test'|'_compress'|'_extract_images'|'_prune') _error 'There were issues with some of the FLAC files, please check:' ;; '_aucdtect') _error 'Some FLAC files may be lossy sourced, please check:' ;; '_replaygain') _error 'There were issues adding ReplayGain values, please check:' ;; '_retag') _error 'Some FLAC files have missing tags or there were issues with some' _error 'of the FLAC files, please check:' ;; esac _error "${cyan}${log_file}$reset" _error 'for details.' } # # Display a banner before script execution which shows all options # configured as well as runtime information # # The structure of the banner: # # -------------------------------------------------------------------- # Runtime Information # -------------------------------------------------------------------- # redoflacs 1.0 # FLAC 1.5.0 # Global Jobs 16 # Compression Jobs 8 # Compression Threads 2 # -------------------------------------------------------------------- # Configuration Options () # -------------------------------------------------------------------- # preserve_modtime true # prepend_zero true # compression_level 8 # remove_artwork true # skip_lossy true # error_log # temporary_wav_location # spectrogram_location # artwork_location # -------------------------------------------------------------------- # # Where the width of the banner is dynamically sized to the width of the # terminal or to the maximum length supported on script execution # _runtime_banner() { local i option field value line align_cpu job_type # Generate a blue line the which matches the maximum length supported printf -v line "%$(( MAX_LENGTH - 1 ))s" '' line="${blue}${line// /-}$reset" # Replace $HOME with tilde local config_file_title="$config_file" config_file_title="~${config_file_title#"$HOME"}" # Dynamically center banner config title local config_title="Configuration Options ($config_file_title)" local config_title_center=$(( (MAX_LENGTH / 2) - (${#config_title} / 2) )) # Dynamically center banner runtime title local runtime_title='Runtime Information' local runtime_title_center=$(( (MAX_LENGTH / 2) - (${#runtime_title} / 2) )) # Dynamically center configuration and runtime options and values # # NOTE # The '- 1' accounts for space between field and value local fields_center=$(( (MAX_LENGTH / 2) - 1 )) # Runtime information printf '%s\n' "$line" printf "\033[$(_row);${runtime_title_center}H%s\n" "$runtime_title" printf '%s\n' "$line" # Display centered script runtime information printf "%${fields_center}s ${cyan}%s${reset}\n" 'redoflacs' "$script_version" printf "%${fields_center}s ${cyan}%s${reset}\n" 'FLAC' "$flac_version" # Multithreaded FLAC distinguishes global jobs from jobs [[ "$multithreaded_flac" ]] && job_type='Global Jobs' || job_type='Jobs' # Choose the longest character value between all jobs and threads align_cpu="${#compress_jobs}" (( align_cpu < ${#compress_threads} )) && align_cpu="${#compress_threads}" (( align_cpu < ${#global_jobs} )) && align_cpu="${#global_jobs}" # Let the user know of possible CPU starvation if the number of global # jobs is higher than total cores available if [[ "$cpu_job_starvation" ]]; then printf \ "%${fields_center}s ${red}%-${align_cpu}s$reset ${yellow}%s$reset\n" \ "$job_type" "$global_jobs" '(CPU Starvation Risk!)' # Display jobs which appear to be reasonable else printf "%${fields_center}s ${cyan}%s${reset}\n" "$job_type" "$global_jobs" fi # Let the user know of possible CPU starvation if the number of # compression jobs and compression threads specified are higher than # total cores available if [[ "$multithreaded_flac" && "$cpu_thread_starvation" ]]; then printf \ "%${fields_center}s ${red}%-${align_cpu}s$reset ${yellow}%s${reset}\n" \ 'Compression Jobs' "$compress_jobs" '(CPU Starvation Risk!)' printf \ "%${fields_center}s ${red}%-${align_cpu}s$reset ${yellow}%s${reset}\n" \ 'Compression Threads' "$compress_threads" '(CPU Starvation Risk!)' # Display compression jobs and compression threads which appear to be # reasonable elif [[ "$multithreaded_flac" ]]; then printf "%${fields_center}s ${cyan}%s${reset}\n" \ 'Compression Jobs' "$compress_jobs" printf "%${fields_center}s ${cyan}%s${reset}\n" \ 'Compression Threads' "$compress_threads" fi # Configuration options printf '%s\n' "$line" printf "\033[$(_row);${config_title_center}H%s\n" "$config_title" printf '%s\n' "$line" # Display all configured options, centered for option in "${config_options[@]}"; do # Option field (shell variable) field="${option%%=*}" # Option value (shell variable value), with a default if empty value="${!field:-${magenta}${reset}}" # Eg: $HOME -> ~/ # shellcheck disable=SC2088 [[ "$value" == "$HOME" ]] && value='~/' # Eg: $HOME/user/dir -> ~/user/dir # shellcheck disable=SC2088 [[ "$value" == "$HOME"/* ]] && value="~/${value#"$HOME"/}/" # Display centered configuration option and value printf "%${fields_center}s ${cyan}%s${reset}\n" "$field" "$value" done printf '%s\n' "$line" } # # Print out current operation title # # The structure of an operation title: # # ' (<status>) (<number_of_issues>)' # # Which may look like: # # '* Verifying MD5... COMPLETED' # '* Testing... COMPLETED 10 issues' # '* Pruning METADATA... INTERRUPTED' # '* Compressing To Level 8... INTERRUPTED 1 issues' # '* Extracting Images... DID NOT RUN' # # Where the status may or may not be displayed and/or the number of # issues may or may not be displayed # _operation_title() { local fmt status_banner issues_banner right_align # Total number of issues found local -i issues issues="$(_num_issues)" # Ensure we do not run if running from a countdown or before an # operation begins (only if SIGINT was sent during these times) [[ "${FUNCNAME[1]}" == '_cleanup' && -z "$operation" ]] && return 0 # Generate a banner indicating total issues found so far if (( issues > 0 )); then issues_banner="$issues issue" # Add plural for more than one issue found (( issues != 1 )) && issues_banner+='s' fi # Generate an interrupted banner if the user sent SIGINT otherwise # default to a completed status # # NOTE: # The parent function will be the signal handler for SIGINT if [[ "${FUNCNAME[1]}" == '_cleanup' ]]; then status_banner='INTERRUPTED' # Add an extra space to separate issues banner if it exists (( issues > 0 )) && status_banner+=' ' # Generate a completed banner if the current operation finished # processing all items elif (( num == ${#total_items[@]} )); then status_banner='COMPLETED' # Add an extra space to separate issues banner if it exists (( issues > 0 )) && status_banner+=' ' fi # Determine where to place cursor in terminal column to right align # possible SIGINT interruption and the number of issues to display right_align=$(( MAX_LENGTH - ${#status_banner} - ${#issues_banner} )) fmt+=$"\033[${title_row}H" # Cursor row; beginning of line fmt+=" ${green}*$reset " # Colored title prefix fmt+='%s' # Modifier; title of operation # Cursor at operation title; right-aligned for issues banner fmt+=$"\033[${title_row};${right_align}H" # These may not be displayed if no issues, no SIGINT, or not completed if [[ "$status_banner" == 'INTERRUPTED'* ]]; then fmt+="${cyan}${status_banner}$reset" # Interrupted banner else fmt+="${green}${status_banner}$reset" # Completed banner fi fmt+="${red}${issues_banner}$reset\n" # Issues banner # Display generated operation title message # shellcheck disable=SC2059 printf "$fmt" "${operation_title[$operation]}" } # # Displays the currently processed item on a dedicated row within the # terminal # # The filename is truncated if the length of the filename is greater # than 80 characters or if greater than the width of the terminal if # that terminal width is less than 80. This width is set by 'MAX_LENGTH' # # Filename truncation also occurs to allow the operation label (via $1) # and the total number of completed items to be displayed on the same # row as the displayed item # # The structure of a displayed item: # '<percent> <basename> <operation_label> <number_completed>' # # Which may look like: # ' 06 - Some Amazing Song.flac Testing 6/20' # # This function does NOT create the display the percentage completed. # That is handled later # # The file basename is left-aligned (leaving room for the percentage), # while the operation label and total number of completed items are # right-aligned # # An example of filename truncation: # '01 - This is a long name showing truncation.flac' # # This filename would be displayed during an operation as: # ' 01 - This is a long name showing truncation.fla Testing 6/20' # # An example of how this would look with a percentage displayed: # ' 56% 01 - This is a long name showing truncation.fla Testing 6/20' # _print_item() { local fmt truncated local basename="${item##*/}" local label="${1:+ $1}" # Operation label (with leading space) # The total number of items out of the total number of items, used to # determine the maximum width these values can use, eg: # ' 237/237' # # The leading space is intentional, to ensure a space is between the # item basename and this value local total=" ${#total_items[@]}/${#total_items[@]}" # This is the column position after the item basename is displayed. # This allows the operation label and total number of completed items # to be displayed after this column, effectively truncating the # current item if longer than this column position # # NOTE: # The item basename may be longer than the terminal width, which is # why line wrapping is disabled and restored, to prevent the display # of the item basename from traversing multiple lines truncated="$(( MAX_LENGTH - ${#total} - ${#label} ))" # Disable line wrapping if enabled [[ "$LINE_WRAPPING" ]] && fmt+=$'\033[?7l' # Belows builds the 'printf' format string fmt+=$"\033[${row};6H" # Cursor row; 6 columns to right fmt+=$"\033[0K" # Erase line from cursor to right fmt+='%s' # Modifier; Current item basename fmt+=$"\033[${row};${truncated}H" # Cursor row; Move to allow items fmt+=$"\033[0K" # Erase line from cursor to right fmt+="${magenta}%s" # Modifier; Operation label fmt+="${cyan}%${#total}s${reset}" # Modifier; Items, right-aligned # Restore line wrapping if enabled [[ "$LINE_WRAPPING" ]] && fmt+=$'\033[?7h' # Displays formatted, currently processed item # shellcheck disable=SC2059 printf "$fmt" "$basename" "$label" "${num}/${#total_items[@]}" } # # These functions are used after '_print_item()' to update/indicate the # status of a given item in an operation # _print_item_ok() { printf '%b' "\033[${row}H${green}100%$reset"; } _print_item_fail() { printf '%b' "\033[${row}H${red}fail$reset"; } _print_item_check(){ printf '%b' "\033[${row}H${yellow}chck$reset"; } _print_item_half() { printf '%b' "\033[${row}H$yellow 50%$reset"; } _print_item_skip() { printf '%b' "\033[${row}H${cyan}skip$reset"; } # # Manage termination and cleanup of this script # # The signal processed by this function is via $exit_code, which, if # non-zero, is from a trap capturing a termination signal # # Abnormal signals caught will perform cleanup by removing files and # killing any jobs which may be running # # On script end, any operation that was not run is displayed, eg: # # '* Testing... COMPLETED 10 issues' # '* Compressing To Level 8... DID NOT RUN' # '* Retagging... DID NOT RUN' # '* Pruning METADATA... DID NOT RUN' # _cleanup() { local i fmt op match status_banner right_align # Total number of issues found local -i issues issues="$(_num_issues)" # If this global variable is non-zero, a trap caught an abnormal # signal and redefined it to an exit code value matching the signal # received if (( exit_code != 0 )); then _operation_title # Display operation was interrupted _kill_jobs "$(jobs -rp)" # Kill any running jobs # Clear all the operation lines by moving up each line and clearing # it until we are just below the operation's title message for (( i=1; i<=items_processed; i++ )); do printf '%b' "\033[$(( post_row - i ))H\033[0K" done # Remove temporary WAV and FLAC files rm -rf "${temporary_wav_location_sub_dir:-/dev/null/foo}" rm -f "${directory:-/dev/null/foo}"/**/*_redoflacs_$$.wav rm -f "${directory:-/dev/null/foo}"/**/*.tmp,fl-ac+en\'c # Remove temporary picture blocks (from artwork extraction) rm -f /tmp/redoflacs_picture_blocks.[[:digit:]]* fi # Due to catching an abnormal signal or if there were issues found, # loop through all operations and display any operation which did not # run for op in "${operations[@]}"; do # Matched operation is either interrupted or completed with issues [[ "$op" == "$operation" ]] && match='true' && continue # Skip completed operations (without issues) [[ ! "$match" ]] && continue status_banner='DID NOT RUN' # Determine where to place cursor in terminal column to right # align the operation status right_align=$(( MAX_LENGTH - ${#status_banner} )) fmt=" ${green}*$reset " # Colored title prefix fmt+='%s' # Modifier; title of operation # Cursor at operation title; right-aligned for issues banner fmt+=$"\033[$(_row);${right_align}H" # Operation status banner fmt+="${magenta}${status_banner}$reset\n" # Display generated operation title message # shellcheck disable=SC2059 printf "$fmt" "${operation_title[$op]}" done # Ensure user is notified of a log file on script interruption/exit [[ -f "$log_file" ]] && _display_issues_exist # Remove temporary job FIFO and temporary errors/issues file rm -f "${job_fifo:-/dev/null/foo}" rm -f "${issue_ticks:-/dev/null/foo}" # Restore terminal cursor printf '\033[?25h' # Issues found without an abnormal signal (( exit_code == 0 && issues > 0 )) && exit_code=1 } # # Kill any children process (obtained via $@), hiding errors and # suppressing the shell's notification of terminated jobs # _kill_jobs() { local pid for pid in "$@"; do kill "$pid" 2>/dev/null wait "$pid" 2>/dev/null done } # # Return the current cursor's row position by sending the following # control sequence to the controlling terminal: # CSI 6 n # Query cursor position # # Which reports/returns the cursor position/terminal size as: # CSI height ; width R # eg. '\033[80;23R' # _row() { local -i row # Send CSI escape to (and read from) STDERR to not pollute STDIN IFS='[;' read -rsu2 -d'R' -t1 -p $'\033[6n' _ row _ printf '%d' "${row:-0}" # Default to 0, if failure } # # Return the current cursor's column position by sending the following # control sequence to the controlling terminal: # CSI 6 n # Query cursor position # # Which reports/returns the cursor position/terminal size as: # CSI height ; width R # eg. '\033[80;23R' # _col() { local -i col # Send CSI escape to (and read from) STDERR to not pollute STDIN IFS='[;' read -rsu2 -d'R' -t1 -p $'\033[6n' _ _ col printf '%d' "${col:-0}" } # # If necessary, scroll the terminal # _scroll_terminal() { local -i row to_scroll fmt # Obtain current row position row="$(_row)" # Default to scrolling the terminal based on the number of jobs to_scroll="$operation_jobs" # Scrolling by number of items to process (( operation_jobs > ${#total_items[@]} )) && to_scroll="${#total_items[@]}" # Only scroll if there are more items to display than lines available if (( --to_scroll > (lines - row) )); then # Scroll terminal the required number of lines printf '%b' "\033[$(( to_scroll - (lines - row) ))S" # Place cursor back to where it was in relation to current output printf '%b' "\033[$(( lines - to_scroll ))H" fi } # # Create a configuration file # _create_config() { local -i item=0 # Ensure the parent directory exists mkdir -p "${config_file%/*}" # If there already is a configuration file, do not overwrite it until [[ ! -f "$config_file" ]]; do config_file="${HOME}/.config/redoflacs/config.$(( ++item ))" done # Don't expand variables when using heredoc cat > "$config_file" << "END_OF_CONFIG" # # redoflacs configuration # # Any line that is _NOT_ prepended with a '#' will be interpreted as an option # (except for blank lines -- these are not interpreted) # #------------------------------------------------------------------------------ # TAGGING SECTION #------------------------------------------------------------------------------ # # List the tags to be kept in each FLAC file. The default is listed below. # # Another common tag not added by default is ALBUMARTIST. Uncomment ALBUMARTIST # below to allow script to keep this tag. # # NOTE: Whitespace _IS_ allowed for the these tag fields, ie: # ALBUM ARTIST # CATALOG NUMBER ISBN # TITLE ARTIST #ALBUMARTIST ALBUM DISCNUMBER DATE TRACKNUMBER TRACKTOTAL GENRE # The COMPRESSION tag is a custom tag to allow the script to determine which # level of compression the FLAC file(s) has/have been compressed at. COMPRESSION # The RELEASETYPE tag is a custom tag the author of this script uses to # catalog what kind of release the album is (ie, Full Length, EP, Demo, etc.). RELEASETYPE # The SOURCE tag is a custom tag the author of this script uses to catalog # which source the album has derived from (ie, CD, Vinyl, Digital, etc.). SOURCE # The MASTERING tag is a custom tag the author of this script uses to catalog # how the album has been mastered (ie, Lossless, or Lossy). MASTERING # The REPLAYGAIN tags below, are added by the '-g' or '-G' argument. # If you want to keep the ReplayGain tags, make sure you leave these here. REPLAYGAIN_REFERENCE_LOUDNESS REPLAYGAIN_TRACK_GAIN REPLAYGAIN_TRACK_PEAK REPLAYGAIN_ALBUM_GAIN REPLAYGAIN_ALBUM_PEAK #------------------------------------------------------------------------------ # OPTIONS #------------------------------------------------------------------------------ # # The options listed below are shell syntax where option values are # evaluated/interpolated as expected with the caveat that command substitutions # are not allowed, eg: # # error_log='$HOME/logs' # $HOME/logs # error_log='${HOME}/logs' # ${HOME}/logs # error_log="$HOME/logs" # /home/user/logs # error_log="${HOME}/logs" # /home/user/logs # error_log="$(pwd)/logs" # $(pwd)/logs # error_log="`pwd`/logs" # `pwd`/logs # error_log="${HOME}/$(date)/logs" # ${HOME}/$(date)/logs # error_log='${HOME}/$(date)/logs' # ${HOME}/$(date)/logs # # Any single quoted value and values with command substitutions are treated as # literal strings. # #------------------------------------------------------------------------------ # # PRESERVE FILE MODIFICATION TIME: # # Changes whether both 'flac' and 'metaflac' programs ensure the updated files # have the same timestamps/permissions as the input files. # # For 'flac' usage, this is the default, but 'metaflac' does update timestamps # and permissions. # # This is enabled by setting 'preserve_modtime' option as 'true'. All other # values are interpreted as 'false'. # preserve_modtime='true' # # PREPEND TRACK NUMBER: # # Change whether the retag (-r) operation will re-tag singular track numbers # and track totals from: # 1, 2, 3, 4, 5, 6, 7, 8, 9 # to # 01, 02, 03, 04, 05, 06, 07, 08, 09 # # For example, if you had: # TRACKNUMBER=4 # TRACKTOTAL=9 # # You would end up with: # TRACKNUMBER=04 # TRACKTOTAL=09 # # This is enabled by setting 'prepend_zero' option as 'true'. All other values # are interpreted as 'false'. # prepend_zero='false' # # SET COMPRESSION: # # Set the type of COMPRESSION strength when compressing the FLAC files. Numbers # range from '1-8', with '1' being the lowest compression and '8' being the # highest compression. The default is '8'. # compression_level='8' # # REMOVE ARTWORK: # # Set whether to remove embedded artwork within FLAC files. By default, this # script will remove any artwork it can find in the PICTURE block of a FLAC # file. Set 'remove_artwork' as 'true' to remove embedded artwork. All other # values are intepreted as 'false'. # remove_artwork='true' # # AUCDTECT/LAC SKIP LOSSY: # # Set whether FLAC files should be skipped if the MASTERING tag is already set # as 'Lossy' when analyzed with auCDtect or LAC. Set 'skip_lossy' as 'true' to # skip FLAC files that have the tag: 'MASTERING=Lossy'. All other values are # intepreted as 'false'. # skip_lossy='true' # # ERROR LOG DIRECTORY: # # Set where you want error logs to be placed. By default, they are stored in # the user's HOME directory. # # All values for 'error_log' are interpreted as a directory. If left blank, the # default location will be used. # error_log='' # # WAV FILE DIRECTORY # # Set where to place temporary created WAV files when decoding FLAC files for # use with auCDtect or LAC (Lossless Audio Checker). # # By default the WAV file is created in the same location as the FLAC file and # removed when the operation is completed for that file. # # Users may prefer to place all WAV files on TMPFS backed storage or some other # location depending on their use case. # # All values for 'temporary_wav_location' are interpreted as a directory. If # left blank, the default location will be used. # # NOTE: # Setting this value to a directory on a filesystem with minimal space may # increase the chance of filesystem exhaustion if running with a large number # of parallel jobs # temporary_wav_location='' # # SPECTROGRAM DIRECTORY: # # Set where created spectrogram images should be stored. By default, they are # stored in the same directory as the analyzed FLAC files. Each image will have # the same name as the tested FLAC file but with an integer-based suffix to # allow for uniqueness. The type of image created is PNG. # # All values for 'spectrogram_location' are interpreted as a directory. If left # blank, the default location will be used. # # An example of a user-defined location: # # spectrogram_location="${HOME}/specs" # # Example spectrogram: # # ${HOME}/specs/music/artist/album/file.flac.spec.png.0 # spectrogram_location='' # # EXTRACTED ARTWORK DIRECTORY: # # Set where the extracted artwork images should be stored. # # By default, each extracted image will be placed in a subdirectory where the # FLAC file is located. The subdirectory housing the extracted artwork will # have a similar name as the currently processed FLAC. If a directory already # exists, an integer is appended to the directory (to prevent overwriting and # mixing files). For example: # # /music/artist/album/file.flac # FLAC file with embedded images # /music/artist/album/file.flac.art.0/ # Directory housing artwork # /music/artist/album/file.flac.art.1/ # '1' if above directory exists # /music/artist/album/file.flac.art.2/ # '2' if above directory exists # # All values for 'artwork_location' are interpreted as a directory. If left # blank, the default location will be used. # # If there is a user-defined location, the extracted images will be placed in a # subdirectory in that location with a naming scheme similar to above: # # artwork_location="${HOME}/artwork" # User-defined configuration option # /music/artist/album/file.flac # FLAC file with embedded artwork # # # Directory housing artwork (incremented if already existing) # ${HOME}/artwork/music/artist/album/file.flac.art.0/ # ${HOME}/artwork/music/artist/album/file.flac.art.1/ # ${HOME}/artwork/music/artist/album/file.flac.art.2/ # artwork_location='' END_OF_CONFIG # Indicate if there was an existing configuration file if [[ "$config_file" =~ ^.+[[:digit:]]$ ]]; then _warn 'An existing configuration file is here:' _warn "${cyan}${config_file%.*}$reset\n" _info 'A new configuration file was created here:' _info "${cyan}${config_file}$reset\n" _info 'Ensure the new configuration file is copied here:' _info "${cyan}${config_file%.*}$reset\n" _info 'In order for this program to make use of it' # No existing configuration file was found, indicate review of file else _info 'A configuration file has been created here:' _info "${cyan}${config_file}$reset\n" _info 'It is recommended to review the new configuration file before' _info 'running this program.' fi } # # Parse the configuration file # _parse_config() { local line option value valid_option # Used to check for missing, but required, configuration options local -a missing_options # Used in 'flac' and 'metaflac' invocations local -ga flac_extra_options metaflac_extra_options # Used in _retag() function local -ga tags_to_keep # Used in script banner local -ga config_options # Skip irrelevant configuration file lines local regex_skip='^([[:space:]]+|#.*|)$' # Matches on configuration options (shell variables) local regex_options='^([^[:space:]]+=.*)$' # Matches on possible command substitutions in option values local regex_cmds='\$\(|`' # Unnecessary, as these variables are dynamically exported from the # config but are defined to silence shellcheck of unused variables local -g \ preserve_modtime \ prepend_zero \ compression_level \ remove_artwork \ skip_lossy \ error_log \ temporary_wav_location \ spectrogram_location \ artwork_location # Key/value associative array used to evaluate valid/missing options local -A valid_config_options valid_config_options=( [preserve_modtime]='true' [prepend_zero]='true' [compression_level]='true' [remove_artwork]='true' [skip_lossy]='true' [error_log]='true' [temporary_wav_location]='true' [spectrogram_location]='true' [artwork_location]='true' ) # Process the configuration file, storing all user-defined tags and # evaluate each configuration option while IFS=$'\n' read -r line; do # Skip empty lines, comment lines and lines with whitespace [[ "$line" =~ $regex_skip ]] && continue # Non-option (shell variables) are treated as user-defined FLAC tags if [[ ! "$line" =~ $regex_options ]]; then # FLAC tags stored into a global array tags_to_keep+=( "${line^^}" ) continue fi # Skip if option is not part of the valid options [[ ! "${valid_config_options["${line%%=*}"]}" ]] && continue # Store configuration option key/value config_options+=( "$line" ) # Break up line into shell variable and value IFS='=' read -r option value <<< "$line" # Remove leading/trailing whitespace from value (if any) IFS=$' \t\n' read -r value <<< "$value" # Do not interpolate value if single quotes are around value if [[ "$value" =~ ^\'.*\'$ ]]; then # Remove single quotes from value value="${value#\'}" value="${value%\'}" # Set configuration option and value into environment export "$option"="$value" continue fi # Remove double quotes from value (if any) value="${value#\"}" value="${value%\"}" # Do not interpolate possible command subsitutions and only # interpolate values which contain a variable prefix # # NOTE: # BASH >= 4.4.x allows for parameter transformations which are # safer than using 'eval' if [[ ! "$value" =~ $regex_cmds && "$value" == *\$* ]]; then if (( ${BASH_VERSINFO[0]}${BASH_VERSINFO[1]} >= 44 )); then value="${value@P}" # Expand like a prompt string else value="$(eval "printf '%s' \"$value\"" 2>/dev/null)" fi fi # Interpolate configuration option and value into environment export "$option"="$value" done < "$config_file" # Look for any missing options in the user's configuration file and # notify the user for valid_option in "${!valid_config_options[@]}"; do if [[ " ${config_options[*]%%=*} " != *" $valid_option "* ]]; then missing_options+=( "$valid_option" ) fi done # Exit if there were any missing options from the configuration file if [[ "${missing_options[*]}" ]]; then _error 'The following required options are missing:' for option in "${missing_options[@]}"; do _error " ${cyan}${option}$reset" done _error _error 'Recreate a configuration file (non-destructive) via:' _error " ${cyan}redoflacs -o $reset" exit 1 fi # Handle timestamp preseveration for both 'flac' and 'metaflac' if [[ "${preserve_modtime,,}" == 'true' ]]; then flac_extra_options+=( '--preserve-modtime' ) metaflac_extra_options+=( '--preserve-modtime' ) # Metaflac defaults to not preserving modification times and does not # have a flag to turn preservation off else flac_extra_options+=( '--no-preserve-modtime' ) fi } # # Display countdown before retagging to allow user to quit script safely # _countdown_metadata() { # Warning message _error "${yellow}CAUTION!${reset} These are the tag fields that will be kept" _error 'when re-tagging the selected files:\n' # Creates the listing of tags to be kept printf ' %s\n' "${tags_to_keep[@]}" printf '\n' # Warning message about embedded coverart _error "By default, this script will ${red}REMOVE${reset} the legacy" _error "${cyan}COVERART${reset} tag.\n" _error "Add the ${cyan}COVERART${reset} tag to the list of tags to be kept" _error "in the ${cyan}TAGGING SECTION${reset} of the configuration file.\n" _error "Keep in mind, if the ${cyan}remove_artwork${reset} option is set to" _error "${cyan}false${reset}, embedded artwork in the ${cyan}PICTURE${reset}" _error "block will be kept when using the ${cyan}-p, --prune${reset} option" _error "as well.\n" _warn "Waiting ${red}20${reset} seconds before starting program..." _warn 'Ctrl+C (Control-C) to abort...\n' printf '%b' " ${green}*${reset} Starting in: " # 10 second countdown for count in {20..1}; do printf "${red}%d ${reset}" "$count" read -rt1 # Sleep 1 done printf '\n' } # # Return an integer detailing the number of issues an operation may have # had, returning '0', if no issues were found # _num_issues() { local ticks # Read in number of issue ticks, hiding missing file output { read -r ticks < "$issue_ticks"; } 2>/dev/null printf '%d' "${#ticks}" # Return number of ticks (0, if empty) } # # Obtain and process the positional parameters invoked with the script # # The following global variables are defined, should options which # define them are specified: # # $create_spectrogram # $directory # $force_compression # $force_replaygain # $no_extra_tags # $operations[@] # _process_arguments() { # Global variables used in other functions/operations local -g create_spectrogram force_compression force_replaygain \ no_extra_tags directory operations global_jobs compress_jobs \ compress_threads local arg invalid op local -a conflicting operations_to_run # If no arguments are made to the script show usage and help if (( $# == 0 )); then _help exit 1 fi # Processes all the possible positional parameters, short options # only, handling conflicting parameters while getopts ':j:J:T:mtcCaAgLlGrepnhvox' arg "$@"; do case "$arg" in # Given argument is not valid. 'OPTARG' is 'arg' before 'arg' is # set to '?' '?') [[ "$invalid" == *"-$OPTARG"* ]] && continue invalid+="${invalid:+ }-$OPTARG" ;; # Required parameter for a given argument was not specified ':') _error "Option -$OPTARG, requires a non-zero integer, eg: -${OPTARG}4" _help exit 1 ;; # Help message and exit 'h') _help; exit 0 ;; # Dipslay script version and exit 'v') printf 'redoflacs %s\n' "$script_version"; exit 0 ;; # Create a new configuration file and exit 'o') _create_config; exit 0 ;; # Disable color output 'n') unset -v 'reset' 'red' 'green' 'yellow' 'blue' 'magenta' 'cyan' ;; # Prevent COMPRESSION tag from being applied during compression 'x') no_extra_tags='true' ;; # Validate MD5 checksum in FLAC files 'm') operations_to_run+=( '_md5check' ) ;; # Test FLAC files 't') operations_to_run+=( '_test' ) ;; # Compression and force compression arguments 'c'|'C') # Skip as both compression arguments were already specified [[ "${conflicting[*]}" == *'-c, -C'* ]] && continue operations_to_run+=( '_compress' ) [[ "$arg" == 'C' ]] && force_compression='true' # '-C' was already specified and '-c' is the current argument if [[ "$arg" == 'c' && "$force_compression" ]]; then conflicting+=( '-c, -C' ) # '-c' was already specified and '-C' is the current argument elif [[ "$arg" == 'C' && ! "$force_compression" ]]; then conflicting+=( '-c, -C' ) fi ;; # auCDtect and auCDtect with spectrogram arguments 'a'|'A') # Skip as both auCDtect arguments were already specified [[ "${conflicting[*]}" == *'-a, -A'* ]] && continue operations_to_run+=( '_aucdtect' ) [[ "$arg" == 'A' ]] && create_spectrogram='true' # '-A' was already specified and '-a' is the current argument if [[ "$arg" == 'a' && "$create_spectrogram" ]]; then conflicting+=( '-a, -A' ) # '-a' was already specified and '-A' is the current argument elif [[ "$arg" == 'A' && ! "$create_spectrogram" ]]; then conflicting+=( '-a, -A' ) fi ;; # LAC and LAC with spectrogram arguments 'l'|'L') # Skip as both LAC arguments were already specified [[ "${conflicting[*]}" == *'-l, -L'* ]] && continue operations_to_run+=( '_lac' ) [[ "$arg" == 'L' ]] && create_spectrogram='true' # '-L' was already specified and '-l' is the current argument if [[ "$arg" == 'l' && "$create_spectrogram" ]]; then conflicting+=( '-l, -L' ) # '-l' was already specified and '-L' is the current argument elif [[ "$arg" == 'L' && ! "$create_spectrogram" ]]; then conflicting+=( '-l, -L' ) fi ;; # ReplayGain and force ReplayGain arguments 'g'|'G') # Skip as both ReplayGain arguments were already specified [[ "${conflicting[*]}" == *'-g, -G'* ]] && continue operations_to_run+=( '_replaygain' ) [[ "$arg" == 'G' ]] && force_replaygain='true' # '-G' was already specified and '-g' is the current argument if [[ "$arg" == 'g' && "$force_replaygain" ]]; then conflicting+=( '-g, -G' ) # '-g' was already specified and '-G' is the current argument elif [[ "$arg" == 'G' && ! "$force_replaygain" ]]; then conflicting+=( '-g, -G' ) fi ;; # Retag FLAC files 'r') operations_to_run+=( '_retag' ) ;; # Extract embedded artwork from FLAC files 'e') operations_to_run+=( '_extract_images' ) ;; # Prune unnecessary metadata blocks from FLAC files 'p') operations_to_run+=( '_prune' ) ;; # Specify number of jobs/threads 'j'|'J'|'T') # OPTARG is the (required) number after $arg if [[ ! "$OPTARG" =~ ^[[:digit:]]+$ ]] || (( OPTARG == 0 )); then _error "Option -$arg, requires a non-zero integer, eg: -${arg}4" _error "Current value applied: $OPTARG" _help exit 1 fi # Set the relevant jobs/threads based on type of argument [[ "$arg" == 'j' ]] && global_jobs="$OPTARG" [[ "$arg" == 'J' ]] && compress_jobs="$OPTARG" [[ "$arg" == 'T' ]] && compress_threads="$OPTARG" ;; esac done # Invalid parameters were specified if [[ "$invalid" ]]; then _error 'Invalid arguments specified:' _error " ${cyan}${invalid}$reset" fi # Conflicting parameters were specified if (( ${#conflicting[@]} > 0 )); then _error 'Conflicting operations specified:' for option in "${conflicting[@]}"; do _error " ${cyan}${option}$reset" done fi # Display help and exit if invalid or conflicting parameters exist if [[ "$invalid" ]] || (( ${#conflicting[@]} )); then _help exit 1 fi # Clear current arguments processed by getopts, including the last # non-option encountered shift $(( OPTIND - 1 )) # Make sure a directory is specified (non-option argument) if (( $# == 0 )); then _error 'Please specify a valid directory!' _help exit 1 # Ensure only directory is specified, which also shows any invalid # non-option arguments applied elif (( $# > 1 )); then _error 'Unexpected arguments:' _error " ${cyan}$*${reset}" _error 'All options must come before the target directory!' _help exit 1 fi # The last element passed in is the directory to process FLAC files directory="${1%/}" # Remove possible ending slash # Ensure the directory exists if [[ ! -d "$directory" ]]; then _error 'Please specify a valid directory!' _help exit 1 fi # Configure the order to run operations as this script may be called # to run one or all operations on a target directory of FLAC files if [[ " ${operations_to_run[*]} " == *' _md5check '* ]]; then operations+=( '_md5check' ) fi if [[ " ${operations_to_run[*]} " == *' _test '* ]]; then operations+=( '_test' ) fi if [[ " ${operations_to_run[*]} " == *' _compress '* ]]; then operations+=( '_compress' ) fi if [[ " ${operations_to_run[*]} " == *' _aucdtect '* ]]; then operations+=( '_aucdtect' ) fi if [[ " ${operations_to_run[*]} " == *' _lac '* ]]; then operations+=( '_lac' ) fi if [[ " ${operations_to_run[*]} " == *' _replaygain '* ]]; then operations+=( '_replaygain' ) fi if [[ " ${operations_to_run[*]} " == *' _retag '* ]]; then operations+=( '_retag' ) fi if [[ " ${operations_to_run[*]} " == *' _extract_images '* ]]; then operations+=( '_extract_images' ) fi if [[ " ${operations_to_run[*]} " == *' _prune '* ]]; then operations+=( '_prune' ) fi } # # Check for missing programs required by this script # _check_missing_programs() { local program local -a programs=( 'rm' 'mkdir' 'mkfifo' 'metaflac' 'flac' ) local -a missing_programs for program in "${programs[@]}"; do type -P "$program" >/dev/null || missing_programs+=( "$program" ) done # auCDtect binary if [[ " ${operations[*]} " == *' _aucdtect '* ]]; then type -P auCDtect >/dev/null || missing_programs+=( 'auCDtect' ) fi # Lossless Audio Checker (LAC) binary if [[ " ${operations[*]} " == *' _lac '* ]]; then type -P LAC >/dev/null || missing_programs+=( 'LAC' ) fi if [[ "$create_spectrogram" ]]; then type -P sox >/dev/null || missing_programs+=( 'sox' ) fi if [[ "${missing_programs[*]}" ]]; then _error 'The following required programs were not found:' for program in "${missing_programs[@]}"; do _error " ${cyan}${program}$reset" done _error # shellcheck disable=SC2016 _error 'Ensure these programs are installed and available in $PATH' exit 1 fi } # # Run a given operation with a specified number of jobs # _run_parallel() { local item # FLAC file (or directory) to process local -i row # Starting cursor row to display first item and operation progress row=$(( $(_row) - 1 )) # Start as many operations as specified by the number of jobs for item in "${total_items[@]:0:$operation_jobs}"; do (( row++, num++ )) "$operation" & done # Wait on operations if number of items is less than total jobs (( ${#total_items[@]} <= operation_jobs )) && wait # Completion of an operation sends an integer (terminal row number) # and newline to a FIFO (job manager). # # After each newline is read from the FIFO, another item is processed while read -r row; do (( num >= ${#total_items[@]} )) && break # Stop if no more FLACs item="${total_items[num++]}" # Current item to process "$operation" & done <&3 # Read from FIFO # Wait on operations wait } # # Sends the value of $row (integer) to the job manager FIFO which is # from the parent function, _run_parallel() # # This value is where the cursor is located for the current operation # on a given item, allowing the job manager to know where to put output # for the next job, if any # # The newline is needed in order for the 'read' to finish reading the # output # _send_operation_finished() { printf >&3 '%d\n' "$row" } # # Decode FLAC to WAV file (used in operation functions) # # NOTE: # This function will return 1 if failure was detected and no other # operations need to be performed (to exit the parent function) # _decode_to_wav() { local output previous error_msg # Basename of FLAC, splitting on whitespace since the 'read' below # splits on whitespace (to get percentage token, error messages, etc) local base="${item##*[ /]}" # The parent function's name, eg: _lac -> lac local function_label="${FUNCNAME[1]#_}" function_label="${function_label^^}" # lac -> LAC # Used in other functions/operations local -g item_wav # Generate the WAV file path to be created from the current FLAC file if [[ "$temporary_wav_location_sub_dir" ]]; then # Eg: /tmp/tmp_redoflacs_dir.1234.1 -> # /tmp/tmp_redoflacs_dir.1234.1/music/artist/album/01.flac item_wav="${temporary_wav_location_sub_dir%/}/${item#/}" # Ensure the relative path is created in the temporary directory mkdir -p "${item_wav%/*}" # Eg: /tmp/tmp_redoflacs_dir.1234.1/music/artist/album/01.flac -> # /tmp/tmp_redoflacs_dir.1234.1/music/artist/album/01_redoflacs_1234.wav item_wav="${item_wav%.*}_redoflacs_$$.wav" else # Eg: /music/artist/album/01.flac -> # /music/artist/album/01_redoflacs_1234.wav item_wav="${item%.*}_redoflacs_$$.wav" fi _print_item 'Decoding' # As indicated by '[FIRST]', '[SECOND]', and '[LAST]', this is the # order each of the statements will evaluate to 'true'. '[FIRST]' may # never evaluate to 'true' (if the FLAC file fails to decode) while IFS=$'\n' read -rd ' ' output; do # [LAST] Store error message if [[ "$error_msg" && -n "$output" ]]; then [[ "$output" == 'state' ]] && break # End of error message error_msg+=" $output" continue fi # [SECOND] An error occurred, storing first word if [[ "$output" == 'ERROR' && "$previous" == *"${base}:" ]]; then error_msg="$output" continue fi # [FIRST] FLAC decoding percentage complete if [[ "$output" == *[[:digit:]]% ]]; then output="${output//$'\b'}" # Remove all backspace characters # Remove beginning 'complete' string (from backspace characters) printf "\033[${row}H${yellow}%4s$reset" "${output#complete}" fi # Used to validate previous output is an error and not a filename previous="$output" done < <(flac 2>&1 "${flac_extra_options[@]}" -d "$item" -o "$item_wav") # Log failures if there are any errors if [[ "$error_msg" ]]; then _print_item_fail printf >> "$log_file" '%s [%s] %s\n' "$item" "$function_label" "$error_msg" printf >> "$issue_ticks" '.' # Add one tick to total issues # Update operation title message with current number of issues _operation_title # Return 1 to break out of parent fuction _send_operation_finished return 1 else _print_item_ok fi } # # Generate a spectrogram of current FLAC (used in operation functions) # _generate_spectrogram() { local output rest_of_output spectrogram # The parent function's name, eg: _aucdtect -> aucdtect local function_label="${FUNCNAME[1]#_}" function_label="${function_label^^}" # aucdtect -> AUCDTECT # Determine where to put spectrograms by checking user config if [[ "$spectrogram_location" ]]; then # Eg: /media/specs -> /media/specs/artist/album/file.flac spectrogram="${spectrogram_location%/}/${item#/}" # Ensure the relative path is created in the spectrogram location mkdir -p "${spectrogram%/*}" # Eg: /media/specs/artist/album/file.flac -> # /media/specs/artist/album/file.flac.spec.png.0 spectrogram="${spectrogram}.spec.png.0" else # Eg: /music/artist/album/file.flac.spec.png.0 spectrogram="${item}.spec.png.0" fi # User requested a spectrogram, SoX is run to generate a spectrogram # in the location as the current item or a user-defined location _print_item 'Spectrogram' # Ensure we don't clobber the spectrogram, eg: # /music/artist/album/file.flac.spec.png.0 -> # /music/artist/album/file.flac.spec.png.1 until [[ ! -f "$spectrogram" ]]; do spectrogram="${spectrogram%.*}.$(( ${spectrogram##*.} + 1 ))" done # SoX display progress percentage on STDERR as well as any error # messages. Example output: # # Error messages: # sox FAIL formats: can't open input file `file.wav': WAVE: RIF<...> # sox FAIL formats: can't open input file `file.flacd': No such<...> while IFS=$'\n' read -rd ' ' output; do if [[ "$output" != *'In:'[[:digit:]]*'%' ]]; then rest_of_output+=" $output" # Re-add spacing continue fi # Strip from leading string output="${output##*In:}" # Strip leading string output="${output%\%}" # Strip trailing percentage output="${output%%.*}" # Strip floating point from output # Re-add percentage when printing printf "\033[${row}H${yellow}%4s$reset" "$output%" done < <( sox 2>&1 "$item" -S -n spectrogram -o "$spectrogram" -t "$item" \ -c '' -p 1 -z 90 -Z 0 -q 249 -w Hann -x 1800 -y 513 ) # Strip leading space from generated output IFS=' ' read -r rest_of_output <<< "$rest_of_output" # Log failures if there are any errors from SoX if [[ "$rest_of_output" == 'sox FAIL'* ]]; then _print_item_fail printf >> "$log_file" \ '%s [%s] %s\n' "$item" "${function_label}:Spectrogram" "$rest_of_output" # Log operation status as well as generated spectrogram location else _print_item_check printf >> "$log_file" \ '%s [%s] %s (%s)\n' \ "$item" "${function_label}:Spectrogram" \ "$lossless_check_results" "$spectrogram" fi printf >> "$issue_ticks" '.' # Add one tick to total issues # Update operation title message with current number of issues _operation_title _send_operation_finished } # # Apply ReplayGain tags to each FLAC file in a given directory. # Conditionally skip FLAC files if ReplayGain tags are already set. If # there are any errors, they are logged # _replaygain() { local output error_msg local -a error_log files # Used to look for error messages and store wanted values local regex="^(${item}/.*): (ERROR:.*)" _print_item 'Applying ReplayGain' # If a user chooses not to force apply ReplayGain tags, then each file # is checked to ensure all ReplayGain tags are available, skipping the # directory of files if all ReplayGain tags are already set # # NOTE: # All errors are ignored here as they will be caught during # application ReplayGain tags if [[ ! "$force_replaygain" ]]; then files=( "$item"/*.flac ) # Used for number of files # Obtain ReplayGain tags from all files in the current directory mapfile -n0 -t replaygain_tags < <( metaflac 2>/dev/null "${metaflac_extra_options[@]}" \ --show-tag='REPLAYGAIN_REFERENCE_LOUDNESS' \ --show-tag='REPLAYGAIN_TRACK_GAIN' \ --show-tag='REPLAYGAIN_TRACK_PEAK' \ --show-tag='REPLAYGAIN_ALBUM_GAIN' \ --show-tag='REPLAYGAIN_ALBUM_PEAK' \ "${files[@]}" ) # All ReplayGain tags are set, so this directory can be skipped # # The total number of ReplayGain tags found on all files in the # current directory must equal the total number of files found # multiplied by 5 (the number of ReplayGain tags added to each file) if (( ${#replaygain_tags[@]} == ${#files[@]} * 5 )); then _print_item_skip _send_operation_finished return fi fi _print_item_half # Metaflac doesn't display a percentage complete # Since this operation applies to multiple files in a given directory, # all error messages (per file) is stored into an array, then logged # to a file # # Metaflac only displays error messages to STDERR, nothing to STDOUT while IFS=$'\n' read -r output; do # Any output is an error message if [[ "$output" =~ $regex ]]; then error_msg="${BASH_REMATCH[1]} [ReplayGain] " # Absolute filename # Error reading FLAC file (possibly fake), eg: # ERROR: reading metadata, status = "FLAC__METADA ..." if [[ "${BASH_REMATCH[2]}" == *", status = "* ]]; then error_msg+="${BASH_REMATCH[2]%, status = *} " # Error # Files with differing sample rates, or problems decoding, eg: # ERROR: sample rate of 44100 Hz does not match ... # ERROR: during analysis (decoding file) else error_msg+="${BASH_REMATCH[2]}" fi # All FLAC files are stored here to be logged error_log+=( "$error_msg" ) fi done < <( metaflac 2>&1 "${metaflac_extra_options[@]}" \ --add-replay-gain "$item"/*.flac ) # Log failures if there are any errors if [[ "${error_log[0]}" ]]; then _print_item_fail printf >> "$log_file" '%s\n' "${error_log[@]}" printf >> "$issue_ticks" '.' # Add one tick to total issues # Update operation title message with current number of issues _operation_title else _print_item_ok fi _send_operation_finished } # # Compress FLAC files with user-defined compression, verifying # integrity. Conditionally skip FLAC files if the 'COMPRESSION' tag # exists for a given file and matches the compression level set in the # user configuration file # _compress() { local file_compression output previous error_msg warning_msg # Basename of FLAC, splitting on whitespace since the 'read' below # splits on whitespace (to get percentage token, error messages, etc) local base="${item##*[ /]}" _print_item 'Compressing' # If a user chooses not to force compression, then each file is # checked for a 'COMPRESSION' tag that matches the compression level # set in the user configuration. If the 'COMPRESSION' tag exists and # matches what is set in the configuration, then this file is skipped # # NOTE: # All errors are ignored here as they will be caught during # compression if [[ ! "$force_compression" ]]; then file_compression="$( metaflac 2>/dev/null "${metaflac_extra_options[@]}" \ --show-tag='COMPRESSION' "$item" )" # 'compression_level' is obtained from the configuration file, in # form of 'COMPRESSION=<value>' if [[ -n "$file_compression" ]] \ && (( ${file_compression##*=} == compression_level )) then _print_item_skip _send_operation_finished return fi fi # As indicated by '[FIRST]', '[SECOND]', '[THIRD]', '[FOURTH]', and # '[LAST]', this is the order each of the statements will evaluate to # 'true'. '[FIRST]' may never evaluate to 'true' (if the FLAC file # fails to decode while IFS=$'\n' read -rd ' ' output; do # [LAST] Store error message if [[ "$error_msg" && -n "$output" ]]; then # End of error message [[ "$output" == 'state' || "$output" == '"flac"' ]] && break error_msg+=" $output" continue fi # [FOURTH] An error occurred, storing first word if [[ "$output" == 'ERROR:' && "$previous" == *"${base}:" ]]; then error_msg="$output" continue fi # [THIRD] Store rest of warning message if [[ "$warning_msg" && -n "$output" ]]; then # Stop if there is an error message and build that separately [[ "$output" == *'ERROR:' ]] && error_msg='ERROR:' && continue warning_msg+=" $output" continue fi # [SECOND] A warning occurred, storing first word # # NOTE: # Due to 'read' using a space a delimiter, the WARNING message may # have preceding text (word + newline) if [[ "$output" == *'WARNING:' ]]; then warning_msg='WARNING:' continue fi # [FIRST] FLAC compression percentage complete if [[ "$output" == *[[:digit:]]% ]]; then output="${output//$'\b'}" # Remove all backspace characters # Remove beginning ratio string (from backspace characters) printf "\033[${row}H${yellow}%4s$reset" "${output#ratio=?.???}" fi # Used to validate previous output is an error and not a filename previous="$output" done < <( flac 2>&1 "${flac_extra_options[@]}" \ -f -"$compression_level" -V "$flac_compress_threads" "$item" ) # Add warning message to error message for logging [[ "$warning_msg" ]] && error_msg="$warning_msg|$error_msg" # Log failures if there are any warnings and/or errors if [[ "$error_msg" ]]; then _print_item_fail error_msg="${error_msg%,}" # Strip possible trailing comma error_msg="${error_msg/$'\n'Type}" # Strip possible multiline error printf >> "$log_file" '%s [Compress] %s\n' "$item" "$error_msg" printf >> "$issue_ticks" '.' # Add one tick to total issues # Update operation title message with current number of issues _operation_title _send_operation_finished return fi # Add a 'COMPRESSION' tag to the current FLAC file, to indicate which # compression level was used when compressing (only if user did not # specify no extra tags to be applied) # # NOTE: # Errors are ignored as the FLAC file was verified during encoding if [[ ! "$no_extra_tags" ]]; then metaflac 2>/dev/null "${metaflac_extra_options[@]}" \ --remove-tag='COMPRESSION' \ --set-tag="COMPRESSION=$compression_level" "$item" fi _print_item_ok _send_operation_finished } # # Tests current FLAC file integrity, reading all output, displaying the # current percentage complete. If there is an error that error is logged # _test() { local output previous error_msg # Basename of FLAC, splitting on whitespace since the 'read' below # splits on whitespace (to get percentage token, error messages, etc) local base="${item##*[ /]}" _print_item 'Testing' # As indicated by '[FIRST]', '[SECOND]', and '[LAST]', this is the # order each of the statements will evaluate to 'true'. '[FIRST]' may # never evaluate to 'true' (if the FLAC file fails to decode) while IFS=$'\n' read -rd ' ' output; do # [LAST] Store error message if [[ "$error_msg" && -n "$output" ]]; then [[ "$output" == 'state' ]] && break # End of error message error_msg+=" $output" continue fi # [SECOND] An error occurred, storing first word if [[ "$output" == 'ERROR' && "$previous" == *"${base}:" ]]; then error_msg="$output" continue fi # [FIRST] FLAC testing percentage complete if [[ "$output" == *[[:digit:]]% ]]; then printf "\033[${row}H${yellow}%4s$reset" "$output" fi # Used to validate previous output is an error and not a filename previous="$output" done < <(flac 2>&1 "${flac_extra_options[@]}" -t "$item") # Log failures if there are any errors if [[ "$error_msg" ]]; then _print_item_fail printf >> "$log_file" '%s [Test] %s\n' "$item" "$error_msg" printf >> "$issue_ticks" '.' # Add one tick to total issues # Update operation title message with current number of issues _operation_title else _print_item_ok fi _send_operation_finished } # # Test FLAC validity with Lossless Audio Checker (LAC) # _lac() { local mastering output rest_of_output spectrogram local -i count # Used in other functions local -g lossless_check_results # Unit separator (replace newlines from LAC's output with US) local us=$'\037' # Used to look for error messages and file status in LAC which is # captured via STDOUT # # Example STDOUT from LAC (error message): # # Lossless Audio Checker 2.0.5 # Copyright (c) 2012-2016 Julien Lacroix & Yann Prime # http://losslessaudiochecker.com # ================================================== # File: invalid_file.wav # Result: WAVE header RIFF chunk: bad Size # # Example STDOUT from LAC (file status): # # Lossless Audio Checker 2.0.5 # Copyright (c) 2012-2016 Julien Lacroix & Yann Prime # http://losslessaudiochecker.com # ================================================== # File: bad_file.wav # Result: Upsampled # # Example regex capture groups obtained: # # Error messages: # # WAVE header RIFF chunk: bad Size # WAVE header FMT chunk: not found # WAVE header EXTENSIBLE chunk: bad Channel Mask # WAVE header DATA chunk: bad ID # # Successful operations: # # Clean # Transcoded # Upscaled # Upsampled local regex="${us}Result: (.*)${us}$" # Check for a result from LAC that is NOT clean local regex_non_clean='^(transcoded|upscaled|upsampled)' _print_item 'LAC' # Get the MASTERING tag of a FLAC file. Errors will be caught during # decoding mastering="$( metaflac 2>/dev/null "${metaflac_extra_options[@]}" \ --show-tag='MASTERING' "$item" )" # Skip FLAC file if MASTERING=Lossy is set and the 'skip_lossy' # configuration option is 'true' if [[ "$mastering" == 'Lossy' && "$skip_lossy" == 'true' ]]; then _print_item_skip _send_operation_finished return fi # Decode FLAC file to WAV file as LAC does not support FLAC or decoded # streams over a pipe # # NOTE: # Break out of current function if there were failures decoding _decode_to_wav || return _print_item 'LAC' # Runs LAC and parses out the progress bar (made up of '=') and # generates a percentage of completion along with storing the result # of the operation # # NOTE: # Processing LAC's output is done by character where 'read' is # reading in a single character at a time while IFS=$'\n' read -rn1 output; do # The first '=' found is the progress bar (max 50), which is counted # and integer scaling division is performed to get the percentage if [[ "$output" == '=' ]]; then printf "\033[${row}H${yellow}%4s$reset" "$(( (++count * 100) / 50 ))%" continue fi # The rest of the output (after progress bar) is the file and result if (( count == 50 )); then # All newlines are null, which is replaced by a unit separator [[ -z "$output" ]] && rest_of_output+="$us" || rest_of_output+="$output" fi done < <(LAC "$item_wav") # Remove temporary WAV file rm -f "${item_wav:-/dev/null/foo}" # Looks for the result from LAC [[ "$rest_of_output" =~ $regex ]] # Either 'Clean', 'Transcoded', 'Upscaled', or 'Upsampled' lossless_check_results="${BASH_REMATCH[1]}" # LAC reports a clean file, continue with next item if [[ "${lossless_check_results,,}" == 'clean' ]]; then _print_item_ok _send_operation_finished return fi # Log failure if not clean and no spectrogram is requested if [[ "${lossless_check_results,,}" =~ $regex_non_clean ]] \ && [[ ! "$create_spectrogram" ]] then _print_item_check printf >> "$log_file" \ '%s [%s] %s\n' "$item" 'LAC' "$lossless_check_results" printf >> "$issue_ticks" '.' # Add one tick to total issues # Update operation title message with current number of issues _operation_title _send_operation_finished return fi # Generate a spectrogram of the current FLAC file and log the # results of the operation along with the spectrogram location _generate_spectrogram } # # Test FLAC validity with auCDtect # # NOTE: # While not officially supported, should a user have a dynamically # linked auCDtect binary, 'MALLOC_CHECK_' will to be set to '0', eg: # # MALLOC_CHECK_='0' auCDtect <rest_of_options> # _aucdtect() { local output aucdtect_stdout aucdtect_stderr spectrogram local -a bits_mastering # Used in other functions local -g lossless_check_results # Used to look for error messages and file status in auCDtect which is # captured via STDOUT # # Example STDOUT from auCDtect (error message): # # auCDtect: CD records authenticity detector, version 0.8 # Copyright (c) 2004 Oleg Berngardt. All rights reserved. # Copyright (c) 2004 Alexander Djourik. All rights reserved. # error: file format is not supported # # Example STDOUT from auCDtect (file status): # # auCDtect: CD records authenticity detector, version 0.8 # Copyright (c) 2004 Oleg Berngardt. All rights reserved. # Copyright (c) 2004 Alexander Djourik. All rights reserved. # ------------------------------------------------------------ # Processing file: [01 - The Manifesto.wav] # # ------------------------------------------------------------ # This track looks like CDDA with probability 85% # # Example regex capture groups obtained: # # Error messages: # # error: can not open file <WAV_FILE> # error: file format is not supported # # Successful operations: # # This track looks like CDDA with probability 100% # This track looks like CDDA with probability 49% # This track looks like MPEG with probability 100% # This track looks like MPEG with probability 92% # Could not qualify the source of this track. local regex=$'(error:.*)|Processing file.*--------.*\n(.*)$' _print_item 'auCDtect' # Get the bit depth and MASTERING tag of a FLAC file. Errors will be # caught during decoding. Indices are: # bits_mastering[0] = bit depth (eg: 16) # bits_mastering[1] = MASTERING tag and value (eg: MASTERING=Lossy) mapfile -n2 -t bits_mastering < <( metaflac 2>/dev/null "${metaflac_extra_options[@]}" \ --show-bps --show-tag='MASTERING' "$item" ) # auCDtect does not support a bit depth greater than 16 (CD quality # only), skipping file # # NOTE: # A default value of 0 is specified in the event the bit depth could # not be obtained. Errors will be caught during decoding if (( ${bits_mastering[0]:-0} > 16 )); then _print_item_skip _send_operation_finished return fi # Skip FLAC file if MASTERING=Lossy is set and the 'skip_lossy' # configuration option is 'true' if [[ "${bits_mastering[1]#*=}" == 'Lossy' && "$skip_lossy" == 'true' ]]; then _print_item_skip _send_operation_finished return fi # Decode FLAC file to WAV file as LAC does not support FLAC or decoded # streams over a pipe # # NOTE: # Break out of current function if there were failures decoding _decode_to_wav || return # Runs auCDtect with medium accuracy setting (for speed): # STDOUT: auCDtect STDOUT captured to be parsed # STDERR: auCDtect progress percentage completed sent to FD4 _print_item 'auCDtect:Fast' exec 4>&1 aucdtect_stdout="$( MALLOC_CHECK_='0' auCDtect -m20 "$item_wav" 2> >( while IFS=$'\n' read -rd $'\r' aucdtect_stderr; do aucdtect_stderr="${aucdtect_stderr##*[}" printf >&4 "\033[${row}H${yellow}%4s$reset" "${aucdtect_stderr%]}" done ) )" exec 4>&- # Looks for error messages and file status from auCDtect # BASH_REMATCH[1]: auCDtect error messages # BASH_REMATCH[2]: auCDtect file status [[ "$aucdtect_stdout" =~ $regex ]] # Store auCDtect status of file lossless_check_results="${BASH_REMATCH[2]}" # Log failures if there are any errors if [[ "${BASH_REMATCH[1]}" ]]; then _print_item_fail printf >> "$log_file" \ '%s [%s] %s\n' "$item" 'auCDtect' "${BASH_REMATCH[1]}" printf >> "$issue_ticks" '.' # Add one tick to total issues # Remove temporary WAV file rm -f "${item_wav:-/dev/null/foo}" # Update operation title message with current number of issues _operation_title _send_operation_finished return # auCDtect reports CDDA of 100%, continue with next item elif [[ "$lossless_check_results" == *'CDDA'*'100%'* ]]; then _print_item_ok # Remove temporary WAV file rm -f "${item_wav:-/dev/null/foo}" _send_operation_finished return fi # Current item is not CDDA of 100%, auCDtect is run with highest # accuracy setting: # STDOUT: auCDtect STDOUT captured to be parsed # STDERR: auCDtect progress percentage completed sent to FD4 _print_item 'auCDtect:Slow' exec 4>&1 aucdtect_stdout="$( MALLOC_CHECK_='0' auCDtect -m0 "$item_wav" 2> >( while IFS=$'\n' read -rd $'\r' aucdtect_stderr; do aucdtect_stderr="${aucdtect_stderr##*[}" printf >&4 "\033[${row}H${yellow}%4s$reset" "${aucdtect_stderr%]}" done ) )" exec 4>&- # Looks for error messages and file status from auCDtect # BASH_REMATCH[1]: auCDtect error messages # BASH_REMATCH[2]: auCDtect file status [[ "$aucdtect_stdout" =~ $regex ]] # Store auCDtect status of file (also used in other functions) lossless_check_results="${BASH_REMATCH[2]}" # Log failures if there are any errors if [[ "${BASH_REMATCH[1]}" ]]; then _print_item_fail printf >> "$log_file" \ '%s [%s] %s\n' "$item" 'auCDtect' "${BASH_REMATCH[1]}" printf >> "$issue_ticks" '.' # Add one tick to total issues # Remove temporary WAV file rm -f "${item_wav:-/dev/null/foo}" # Update operation title message with current number of issues _operation_title _send_operation_finished return fi # auCDtect reports CDDA of 100%, continue with next item if [[ "$lossless_check_results" == *'CDDA'*'100%'* ]]; then _print_item_ok # Remove temporary WAV file rm -f "${item_wav:-/dev/null/foo}" _send_operation_finished return fi # Log failure if not CDDA with 100% and no spectrogram is requested if [[ "$lossless_check_results" != *'CDDA'*'100%'* ]] \ && [[ ! "$create_spectrogram" ]] then _print_item_check printf >> "$log_file" \ '%s [%s] %s\n' "$item" 'auCDtect' "$lossless_check_results" printf >> "$issue_ticks" '.' # Add one tick to total issues # Remove temporary WAV file rm -f "${item_wav:-/dev/null/foo}" # Update operation title message with current number of issues _operation_title _send_operation_finished return fi # Remove temporary WAV file rm -f "${item_wav:-/dev/null/foo}" # Generate a spectrogram of the current FLAC file and log the # results of the operation along with the spectrogram location _generate_spectrogram } # # Check for a valid MD5 checksum in FLAC file # _md5check() { local output error_msg md5sum _print_item 'Verifying MD5' # Metaflac will display the MD5 checksum or an error (on one line). # These values are captured and logged, appropriately while IFS=$'\n' read -r output; do # Error reading FLAC file (possibly fake), eg: # fake/fake.flac: ERROR: reading metadata, status = "FLAC ..." if [[ "$output" == *'ERROR'* ]]; then error_msg="${output%, status = *}" # Strip status error_msg="ERROR${error_msg#*: ERROR}" # Strip filename break # Stop reading lines fi # This is the MD5 checksum md5sum="$output" done < <(metaflac 2>&1 "${metaflac_extra_options[@]}" --show-md5sum "$item") # Log failures if there are any errors if [[ "$error_msg" ]]; then _print_item_fail printf >> "$log_file" '%s [MD5check] %s\n' "$item" "$error_msg" printf >> "$issue_ticks" '.' # Add one tick to total issues # Update operation title message with current number of issues _operation_title # Possible valid FLAC, but MD5 signature is unset in STREAMINFO block elif [[ "$md5sum" == '00000000000000000000000000000000' ]]; then _print_item_fail printf >> "$log_file" '%s [MD5check] %s\n' "$item" \ 'ERROR: Unset MD5 signature (00000000000000000000000000000000)' printf >> "$issue_ticks" '.' # Add one tick to total issues # Update operation title message with current number of issues _operation_title else _print_item_ok fi _send_operation_finished } # # Looks for the tags specified in the configuration file, stripping all # other tags from a given FLAC file, and logging any tags which are # missing # # Process substitution when extracting tags is used to ensure trailing # newlines are not stripped # _retag() { local output error_msg line extracted_tag key empty_tag tag missing_tags_str local -i index='-1' local -a missing_tags found_tags _print_item 'Retagging' # Extracts the first tag from the list of tags to keep and does error # handling on this operation. Other extracted tags are processeed in # the following loop after the error handling # # Metaflac will display errors on one line. These are handled first # with the tag value obtained last (if no failures) while IFS=$'\n' read -r output; do # Error reading FLAC file (possibly fake), eg: # fake/fake.flac: ERROR: reading metadata, status = "FLAC ..." if [[ "$output" == *'ERROR'* ]]; then error_msg="${output%, status = *}" # Strip status error_msg="ERROR${error_msg#*: ERROR}" # Strip filename break # Stop reading lines fi # This is the extracted tag (could be empty or multiple lines) if [[ "$extracted_tag" ]]; then extracted_tag+=$'\n'"$output" else extracted_tag+="$output" fi done < <( metaflac 2>&1 "${metaflac_extra_options[@]}" \ --show-tag="${tags_to_keep[0]}" "$item" ) # Log failures if there are any errors if [[ "$error_msg" ]]; then _print_item_fail printf >> "$log_file" '%s [Retag] %s\n' "$item" "$error_msg" printf >> "$issue_ticks" '.' # Add one tick to total issues # Update operation title message with current number of issues _operation_title _send_operation_finished return fi # Look for each of the tags to keep in the current FLAC flac, # separating each of the tags as separate indices in an array. If # there are any missing tags, these are stored as well for key in "${!tags_to_keep[@]}"; do # The first tag (index=0) was already obtained during error checking if (( key != 0 )); then IFS='' read -rd '' extracted_tag < <( metaflac 2>&1 "${metaflac_extra_options[@]}" \ --show-tag="${tags_to_keep[key]}" "$item" ) # Strips trailing newline due to the process substitution # preserving trailing newlines extracted_tag="${extracted_tag%$'\n'}" fi # The tag doesn't exist in the current item if [[ -z "$extracted_tag" ]]; then missing_tags+=( "${tags_to_keep[key]}" ) continue fi # Processes the extracted tag, looking for multiple tags of the same # type as well and handling multiple line values for a given tag # value. For any valid tags, each are stored as a separate index in # an array # # For any stored tag, the tag field is converted to uppercase and # the tag values are normalized (whitespace, zero-prefixed, etc) while IFS=$'\n' read -r line; do # Tag exists but the value is empty. This may be a missing tag if [[ "${line^^}" == "${tags_to_keep[key]^^}=" ]]; then # If a previous line was an empty tag field, this means the # previous tag was, in fact, empty [[ "$empty_tag" ]] && missing_tags+=( "$empty_tag" ) # Sets the tag field as empty and is used to check any # additional lines which may be part of this tag empty_tag="${tags_to_keep[key]^^}" continue # Tag exists and has a value (first line value only) elif [[ "${line^^}" == "${tags_to_keep[key]^^}="* ]]; then # Strip leading/trailing whitespace IFS=$' \t\n' read -r line <<< "${line#*=}" # Convert TRACKNUMBER and TRACKTOTAL to zero-prefixed numbers if # the 'prepend_zero' configuration option is 'true' if [[ ( "${tags_to_keep[key]}" == 'TRACKNUMBER' || "${tags_to_keep[key]}" == 'TRACKTOTAL' ) && "$prepend_zero" == 'true' ]]; then # Only modify value if the value is an integer if [[ "$line" =~ ^[[:digit:]]+$ ]]; then printf -v line '%02d' $(( 10#$line )) fi fi # Ensure tag field is uppercase and array index, incremented. # The index starts at -1, so is pre-incremented to 0 before # setting a value found_tags[++index]="${tags_to_keep[key]^^}=$line" continue fi # Strip leading/trailing whitespace IFS=$' \t\n' read -r line <<< "$line" # If this variable is set, it means the previous line was an empty # tag field but since this point is reached, the current line is a # part of the tag so the previous line is a valid tag. The tag is # considered NOT empty and is saved with this line as part of the # tag's value. An example tag where this could happen: # # LYRICS= # # This is a line for the above 'LYRICS' tag field. # This is another line which is a part of the same tag. # # The first line (LYRICS=) is originally considered an empty tag, # but the following lines are processed and added to the tag # making this tag valid and non-empty if [[ "$empty_tag" ]]; then found_tags[++index]="${empty_tag}="$'\n'"$line" empty_tag='' # Reset as the tag is considered valid else # Another value (separate line) for the current tag field found_tags[index]+=$'\n'"${line}" fi done <<< "$extracted_tag" done # Runs through the missing tags and only stores the tag as missing if # there are no other tags found which match the tag field. This could # happen, if there are multiple tags of the same tag field and some # are empty, but at least one is valid, eg: # # DATE= # DATE= # DATE=2001 # # The DATE tag is considered missing, but when processed below, would # be considered found and valid (the other empty DATE tags will be # removed) for tag in "${!missing_tags[@]}"; do # Looks through all the valid and normalized tags found for valid_tag in "${found_tags[@]}"; do # Matched a tag and value for a missing tag, no longer missing if [[ "$valid_tag" == "${missing_tags[tag]}="* ]]; then unset -v 'missing_tags[tag]' break fi done done # Removes all tags from the current FLAC file and sets each found tag # all in one operation. A single operation prevents SIGINT from # leaving the current FLAC file without any tags # # Metaflac >= 1.4.3 added '--remove-all-tags-except=NAME1=NAME2...' # which is a safer operation if (( flac_major > 1 || flac_minor > 4 || (flac_minor == 4 && flac_patch >= 3) )) then # Store only the tag fields and join into a '=' separated string printf -v found_tags '%s=' "${found_tags[@]/%=*}" # Ensure trailing '=' is removed metaflac >/dev/null 2>&1 "${metaflac_extra_options[@]}" \ --remove-all-tags-except="${found_tags%=}" "$item" else metaflac >/dev/null 2>&1 "${metaflac_extra_options[@]}" \ --remove-all-tags "${found_tags[@]/#/--set-tag=}" "$item" fi # Logs missing tags, if any were found if [[ "${missing_tags[*]}" ]]; then _print_item_check # Generates a comma-separated list of missing tags to be logged. # Ensures spaces in tag fields are properly handled for tag in "${missing_tags[@]}"; do if [[ "$missing_tags_str" ]]; then missing_tags_str+=", $tag" else missing_tags_str+="$tag" fi done printf >> "$log_file" \ '%s [Retag] Missing tags: %s\n' "$item" "$missing_tags_str" printf >> "$issue_ticks" '.' # Add one tick to total issues # Update operation title message with current number of issues _operation_title else _print_item_ok fi _send_operation_finished } # # Extact embedded artwork from a given FLAC # _extract_images() { local -a artwork local error_msg block_id art_id art_description mime img local blocks="/tmp/redoflacs_picture_blocks.$BASHPID" # Ensure we don't clobber the temporary PICTURE blocks file, eg: # /tmp/redoflacs_picture_blocks.0 -> /tmp/redoflacs_picture_blocks.1 until [[ ! -f "$blocks" ]]; do blocks="${blocks%.*}.$(( ${blocks##*.} + 1 ))" done _print_item 'Extracting Images' # Retrieve PICTURE blocks from current item, to a temporary file error_msg="$( metaflac 2>&1 >"$blocks" "${metaflac_extra_options[@]}" \ --list --block-type=PICTURE "$item" )" # Log FLAC failure if [[ -n "$error_msg" ]]; then error_msg="${error_msg%, status = *}" # Strip end of error msg error_msg="ERROR ${error_msg##*: ERROR: }" # Strip filename _print_item_fail printf >> "$log_file" '%s [ExtractArtwork] %s\n' "$item" "$error_msg" printf >> "$issue_ticks" '.' # Add one tick to total issues rm -f "${blocks:-/dev/null/foo}" # Update operation title message with current number of issues _operation_title _send_operation_finished return fi # Skip if there were no PICTURE blocks found in current FLAC if [[ ! -s "$blocks" ]]; then _print_item_skip rm -f "${blocks:-/dev/null/foo}" _send_operation_finished return fi _print_item_half # Metaflac doesn't display a percentage complete # Store the values which can identify each image (in each PICTURE # block) to be used during image extraction. The values needed are: # 'METADATA block #<num>' -> $block_id # ' type: <num> (<description>)' -> $art_id AND $art_description # ' MIME type: image/jpeg' -> $mime # # NOTE: # Reading the PICTURE block from a temporary file is much faster # than reading from process substitution (there can be many PICTURE # blocks in a single FLAC file and each image could be very large) while IFS=$'\n' read -r; do # Skip all the unnecessary information [[ "$REPLY" != 'METADATA'* && "$REPLY" != ' '[tM]* ]] && continue if [[ "$REPLY" == 'METADATA'* ]]; then block_id="${REPLY##*#}" continue fi # Ensure we don't use: 'type: 6 (PICTURE)' if [[ "$REPLY" == ' type: '[^6]* ]]; then IFS=' ' read -r _ art_id art_description <<< "${REPLY//\//-}" art_description="${art_description//[()]}" # Strip parenthesis art_description="${art_description// /_}" # Spaces underscores continue # Done with a PICTURE block; the values can be stored and the # variables reset elif [[ "$REPLY" == ' MIME'* ]]; then mime="${REPLY##*/}" # Eg: 'MIME type: image/jpeg' -> 'jpeg' mime="${mime/jpeg/jpg}" # Eg: 'jpeg' -> 'jpg' # Eg: '4:3-Cover_front.jpg' artwork+=( "${block_id}:${art_id}-${art_description}.${mime}" ) unset -v 'block_id' 'art_id' 'art_description' 'mime' fi done < "$blocks" rm -f "${blocks:-/dev/null/foo}" # Continue only if there was artwork to be extracted if [[ -z "${artwork[*]}" ]]; then _send_operation_finished return fi # Determine where to put extracted artwork by checking user config if [[ "$artwork_location" ]]; then # Eg: /media/images -> /media/images/artist/album/file.flac artwork_location="${artwork_location%/}/${item#/}" # Ensure the relative path is created in the artwork location mkdir -p "${artwork_location%/*}" # Eg: /media/images/artist/album/file.flac -> # /media/images/artist/album/file.flac.art.0 artwork_location="${artwork_location}.art.0" else # Eg: /music/artist/album/file.flac.art.0 artwork_location="${item}.art.0" fi # Ensure we don't clobber the artwork directory, eg: # /music/artist/album/file.flac.art.0 -> # /music/artist/album/file.flac.art.1 until [[ ! -d "$artwork_location" ]]; do artwork_location="${artwork_location%.*}.$(( ${artwork_location##*.} + 1 ))" done mkdir -p "$artwork_location" # Performs artwork extraction on the current item. Each array value: # '<block_id>:<art_id>-<art_description>.<mime> # # Which looks like: # '4:3-Cover_front.jpg' for value in "${artwork[@]}"; do # Eg: '/music/artist/album/file.flac.art.0/3-Cover_front.jpg.0' img="${artwork_location}/${value#*:}.0" block_id="${value%%:*}" # Eg: '4:3-Cover_front.jpg' -> '4' # Ensure we don't clobber the extracted image, eg: # /music/artist/album/file.flac.art.0/3-Cover_front.jpg.0 -> # /music/artist/album/file.flac.art.0/3-Cover_front.jpg.1 until [[ ! -f "$img" ]]; do img="${img%.*}.$(( ${img##*.} + 1 ))" done # Extracts each image by METADATA block number metaflac "${metaflac_extra_options[@]}" \ --block-number="$block_id" --export-picture-to="$img" "$item" done _print_item_ok _send_operation_finished } # # Prune user configurable FLAC metadata from FLAC files # _prune() { local output error_msg _print_item 'Pruning' _print_item_half # Metaflac doesn't display a percentage complete # Remove all information but STREAMINFO,VORBIS_COMMENTs, and # possibly METADATA_BLOCK_PICTURE while IFS=$'\n' read -r output; do # Error reading FLAC file (possibly fake), eg: # fake/fake.flac: ERROR: reading metadata, status = "FLAC ..." if [[ "$output" == *'ERROR'* ]]; then error_msg="${output%, status = *}" # Strip status error_msg="ERROR${error_msg#*: ERROR}" # Strip filename break # Stop reading lines fi done < <( metaflac 2>&1 "${metaflac_extra_options[@]}" \ --remove --dont-use-padding \ --except-block-type="$dont_prune_flac_metadata" "$item" ) # Log failures if there are any errors if [[ "$error_msg" ]]; then _print_item_fail printf >> "$log_file" '%s [Prune] %s\n' "$item" "$error_msg" printf >> "$issue_ticks" '.' # Add one tick to total issues # Update operation title message with current number of issues _operation_title else _print_item_ok fi _send_operation_finished } ######################### PRE-SCRIPT CHECKS ########################## # For colored output reset=$'\033[0m' red=$'\033[31m' green=$'\033[32m' yellow=$'\033[33m' blue=$'\033[34m' magenta=$'\033[35m' cyan=$'\033[36m' script_version='1.1.0' # Redoflacs version # # Make sure we are running BASH 4 or greater # if (( BASH_VERSINFO[0] < 4 )); then _error "You must be running ${cyan}BASH 4$reset or greater to use this" _error 'program! Current version:' _error " ${cyan}${BASH_VERSION}$reset" exit 1 fi # BASH 4: Used to recursively find (glob) FLAC files (eg, /**/*.flac) shopt -s globstar 2>/dev/null # BASH 2: Make all globbing case-insensitive shopt -s nocaseglob 2>/dev/null # # Redoflacs configuration file # config_file="${HOME}/.config/redoflacs/config" # # Create a new configuration file if one does not exist # if [[ ! -f "$config_file" ]]; then _create_config exit 0 else _parse_config fi # # Process/validate script positional parameters. This function provides # variables which are used by later functions/operations (based on # whether certain operations are specified) # _process_arguments "$@" # # Check for missing programs required by this script # _check_missing_programs # # Manage any user-defined directories from the configuration checking # for directory existence and if writable # # NOTE: # Configuration keys are passed in by name, allowing reference to # derive the key name and indirection for the key value # for dir in \ error_log \ temporary_wav_location \ artwork_location \ spectrogram_location do # Skip any default (empty) configuration values [[ "${!dir}" ]] || continue # Check for existence if [[ ! -d "${!dir}" ]]; then _error "${cyan}${!dir}$reset does NOT exist!" _error # Check for writable elif [[ ! -w "${!dir}" ]]; then _error "${cyan}${!dir}$reset is NOT writable!" _error fi # Rest of error messaging regardless of type of failure if [[ ! -d "${!dir}" || ! -w "${!dir}" ]]; then _error "Ensure a valid value for ${cyan}${dir}$reset is used" _error 'in the following configuration file:' _error " ${cyan}${config_file}$reset" exit 1 fi done # # Generate log file location # # Ensure we don't overwrite an existing log file item=0 log_file="${error_log:-${HOME}}/redoflacs_$$.log" until [[ ! -f "$log_file" ]]; do log_file="${error_log:-${HOME}}/redoflacs_$$.$(( ++item )).log" done # # Generate a unique temporary directory to house temporary WAV files if # a user specified a temporary directory # # Ensure we don't clobber the temporary directory, eg: # /tmp/tmp_redoflacs_dir.1234.0 -> /tmp/tmp_redoflacs_dir.1234.1 # # NOTE: # This directory is only created if an operation will decode FLAC # files to WAV files # if [[ "$temporary_wav_location" ]]; then user_tmp_dir="tmp_redoflacs_dir.$$.0" until [[ ! -d "${temporary_wav_location%/}/$user_tmp_dir" ]]; do user_tmp_dir="${user_tmp_dir%.*}.$(( ${user_tmp_dir##*.} + 1 ))" done # Set the user temporary directory to a unique subdirectory to allow # easy removal after script completion/termination temporary_wav_location_sub_dir="${temporary_wav_location%/}/$user_tmp_dir" fi ############################### MAIN ################################# export LANG=C export LC_ALL=C # Used in _cleanup() (via trap) to determine if an abnormal signal # caught or there generalized (logged) failures caught by the script exit_code=0 # Manage cleanup on interruption and script exit trap 'exit_code=130; exit' SIGINT trap 'exit_code=143; exit' SIGTERM trap '_cleanup; exit $exit_code' EXIT # Hide cursor printf '\033[?25l' job_fifo="/tmp/redoflacs_fifo_$$" # Job manager FIFO issue_ticks="/tmp/redoflacs_issue_file_$$" # Issues/Errors file # Set FLAC metadata BLOCKS to NOT remove, used when pruning FLAC files if [[ "$remove_artwork" == 'true' ]]; then # Remove PICTURE block dont_prune_flac_metadata='STREAMINFO,VORBIS_COMMENT' else # Do not remove PICTURE block dont_prune_flac_metadata='STREAMINFO,PICTURE,VORBIS_COMMENT' fi # # Obtain FLAC version (used in banner) # flac_version="$(flac --version)" # eg: flac 1.4.3 flac_version="${flac_version##* }" # eg: 1.4.3 # # Break out FLAC version string to major, minor and patch # IFS='.' read -r flac_major flac_minor flac_patch <<< "$flac_version" # # Determine the number of cores available, defaulting to 2 # cores='' cores="${cores:-"$(command -p nproc 2>/dev/null)"}" cores="${cores:-"$(command -p getconf _NPROCESSORS_ONLN 2>/dev/null)"}" cores="${cores:-"$(command -p sysctl -n hw.ncpu 2>/dev/null)"}" cores="${cores:-2}" # # FLAC 1.5.0 introduced support for multithreaded encoding of FLAC files # which is checked to support multithreaded usage in compression # operations # if (( flac_major > 1 || flac_minor >= 5 )); then # FLAC version does support multithreaded encoding multithreaded_flac='true' # Compression jobs default to global jobs if global jobs is specified # but no compression jobs were specified [[ "$global_jobs" && ! "$compress_jobs" ]] && compress_jobs="$global_jobs" # Default to number of cores for all other jobs (2, if cores is # undetermined) global_jobs="${global_jobs:-$cores}" # Default to 2 compression threads compress_threads="${compress_threads:-2}" # Default to half as many jobs as threads when compresssing compress_jobs="${compress_jobs:-$(( cores / compress_threads ))}" # Sent to 'flac' during compression (encoding) flac_compress_threads="--threads=$compress_threads" # Check for CPU starvation with compression jobs * threads cpu_thread_starvation='' if (( (compress_jobs * compress_threads) > cores )); then cpu_thread_starvation='true' fi # Check for CPU starvation in user-defined global jobs cpu_job_starvation='' (( global_jobs > cores )) && cpu_job_starvation='true' else # FLAC version does not support multithreaded encoding multithreaded_flac='' # Default to number of cores for all jobs (2, if cores is # undetermined) global_jobs="${global_jobs:-$cores}" # FLAC < 1.5.0 does not support more than one thread compress_threads=1 flac_compress_threads='' cpu_thread_starvation='' # Check for CPU starvation in user-defined global jobs cpu_job_starvation='' (( global_jobs > cores )) && cpu_job_starvation='true' fi # # These are operation title messages which are used by # _operation_title() to dynamically display a title via key lookup # declare -A operation_title operation_title['_md5check']='Verifying MD5...' operation_title['_test']='Testing...' operation_title['_compress']="Compressing To Level ${compression_level}..." operation_title['_aucdtect']='auCDtect Validation...' operation_title['_lac']='Lossless Audio Checker (LAC) Validation...' operation_title['_replaygain']='Applying ReplayGain...' operation_title['_retag']='Retagging...' operation_title['_extract_images']='Extracting Images...' operation_title['_prune']='Pruning METADATA...' # # Obtain the total number of lines and columns available in the terminal # by sending the following control sequences to the terminal driver: # # CSI s # Save cursor position # CSI 999999 999999 H # Move cursor to the end of the last row # CSI 6 n # Query cursor position # CSI u # Restore cursor position # # 'CSI 6 n' reports/returns the cursor position/terminal size as: # # CSI height ; width R # # An example: # # '\033[80;23R' # # Send CSI escape to (and read from) STDERR to not pollute STDIN IFS='[;' \ read -rsu2 -d'R' -t1 -p $'\033[s\033[999999;999999H\033[6n\033[u' \ _ lines columns # # The terminal uses 1-based column indexing (columns start at 1), but # shell arithmetic is 0-based # # To right-align text without manual '+ 1' adjustments in every # calculation, MAX_LENGTH is set to '$columns + 1' # if (( columns < 80 )); then readonly MAX_LENGTH="$(( columns + 1 ))" else readonly MAX_LENGTH='81' fi # # Print a long blank line that is one column higher than the total # columns in the termninal to test for line wrapping # printf "%$(( columns + 1 ))s" '' # # If the current cursor column is equal to the total columns in the # terminal (clamping) then line wrapping is disabled # # If the current cursor column is greater than the total columns in the # terminal (non-clamping) then line wrapping is also disabled # if (( $(_col) >= columns )); then readonly LINE_WRAPPING='' # Move back to column 0 printf '\r' else readonly LINE_WRAPPING='true' # Move back up to the previous row at column 0 printf '\r\033[1A' fi # Display a banner for runtime information and configuration settings _runtime_banner # _retag(): Display a countdown [[ " ${operations[*]} " == *' _retag '* ]] && _countdown_metadata # # Obtain the total FLAC files to process (files and/or directories) # # NOTE: # The array of FLAC files/directories contain absolute pathnames which # allows logging to determine where each item resides # _info 'Looking for FLAC files to process...' pushd "$directory" >/dev/null || exit total_items=( "${PWD}"/**/*.flac ) popd >/dev/null || exit # # If there were no FLAC files found, the glob will exist as index 0, eg: # # /music/artist/album/files/**/*.[Ff][Ll][Aa][Cc] # if [[ ! -f "${total_items[0]}" ]]; then found_flacs_msg='NOT FOUND' # Right align message found_flacs_align=$(( MAX_LENGTH - ${#found_flacs_msg} )) # Update title message with right aligned message indicating there # were no FLAC files found printf "\033[$(( $(_row) - 1 ));${found_flacs_align}H${red}%s${reset}\n" \ "$found_flacs_msg" _error 'There were no FLAC files found!' exit 1 else found_flacs_msg='OK' # Right align message found_flacs_align=$(( MAX_LENGTH - ${#found_flacs_msg} )) # Update title message with right aligned message indicating success # in finding FLAC files to process printf "\033[$(( $(_row) - 1 ));${found_flacs_align}H${green}%s${reset}\n" \ "$found_flacs_msg" fi # # Run through all the operations specified by the user on the provided # FLAC files directory # for operation in "${operations[@]}"; do # Used to determine current item number being processed and used by # the operation title function num=0 # Use compression jobs (tied with threads) for compression operation if [[ "$multithreaded_flac" && "$operation" == '_compress' ]]; then operation_jobs="$compress_jobs" else operation_jobs="$global_jobs" fi # ReplayGain application requires operating on a directory of files # instead of each file, individually if [[ "$operation" == '_replaygain' ]]; then # Backup all known FLAC files to process total_items_backup=( "${total_items[@]}" ) # Obtain all the parent directories for each FLAC file (album) and # store each album directory once into an array to apply ReplayGain # on a directory of files instead of individual files current='' total_items=() for dir in "${total_items_backup[@]%/*}"; do previous="$current" current="$dir" # Non-matching indicates a "new" directory to store [[ "$previous" != "$current" ]] && total_items+=( "$current" ) done fi # Display title message of operation title_row="$(_row)" _operation_title # Scroll terminal up (if needed) _scroll_terminal title_row=$(( $(_row) - 1 )) # Clear job manager file descriptor (tied to FIFO) exec 3<&- 3>&- # Close file descriptor # Remove FIFO if it exists rm -f "${job_fifo:-/dev/null/foo}" mkfifo "$job_fifo" # Create FIFO exec 3<>"$job_fifo" # Open file descriptor read/write # If the items to process are less than the number of jobs, add the # number of items to the current row position, else add the number # of jobs to run if (( ${#total_items[@]} < operation_jobs )); then items_processed="${#total_items[@]}" else items_processed="$operation_jobs" fi post_row=$(( $(_row) + items_processed )) # Operation row position # Run current operation with optional parallel jobs _run_parallel # Clear all the operation lines by moving up each line and clearing it # until we are just below the operation's title message for (( i=1; i<=items_processed; i++ )); do printf '%b' "\033[$(( post_row - i ))H\033[0K" done # Display completed operation _operation_title # Break out if there were any issues found in the current operation # # NOTE: # _cleanup() is run after script exit (( $(_num_issues) > 0 )) && break # Restore the items to process from directories to FLAC files after # applying ReplayGain if [[ "$operation" == '_replaygain' ]]; then total_items=( "${total_items_backup[@]}" ) fi done