Files
2026-06-06 12:24:00 +02:00

3113 lines
98 KiB
Bash
Executable File

#!/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 (<config_path>)
# --------------------------------------------------------------------
# preserve_modtime true
# prepend_zero true
# compression_level 8
# remove_artwork true
# skip_lossy true
# error_log <default>
# temporary_wav_location <default>
# spectrogram_location <default>
# artwork_location <default>
# --------------------------------------------------------------------
#
# 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}<default>${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:
#
# '<prefix> <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