Skip to content

Instantly share code, notes, and snippets.

@KernelGhost
Created September 2, 2026 07:29
Show Gist options
  • Select an option

  • Save KernelGhost/5bc7188d09d7b44b79cfd0e3ce297791 to your computer and use it in GitHub Desktop.

Select an option

Save KernelGhost/5bc7188d09d7b44b79cfd0e3ce297791 to your computer and use it in GitHub Desktop.
A multithreaded bash script that produces MP3 files containing track metadata and embedded album art in a format compatible with iPod Classics running Rockbox.
#!/usr/bin/env bash
################################################################################
# Name: rockbox_converter_mt.sh
# Authors: Rohan Barar
# Revision: 02/09/2026 (DD/MM/YYYY)
# Purpose: Recursively traverse a given input directory to convert all supported
# audio files to Rockbox compatible MP3 files, with a focus on
# ensuring both track metadata (e.g. Title, Artist, Album, etc.) as
# well as embedded album/cover are displayed correctly by Rockbox.
# Usage: ./rockbox_converter_mt.sh <input_dir> <output_dir>
# Example: ./rockbox_converter_mt.sh ~/Desktop/INPUT_DIR/ ~/Desktop/OUTPUT_DIR/
# References:
# - https://www.reddit.com/r/rockbox/comments/gs8e0p/trouble_with_album_art_heres_a_tip/
# - https://askubuntu.com/questions/442997/how-can-i-convert-audio-from-ogg-to-mp3
################################################################################
# Ensure 'nullglob' is enabled for safe wildcard expansion
# Ensure 'nocaseglob' is enabled for case-insensitive wildcard matching
shopt -s nullglob nocaseglob
# Constants
ANSI_RED="\033[1;31m"
ANSI_GREEN="\033[1;32m"
ANSI_GREY="\033[90m"
ANSI_ORANGE="\033[1;38;5;214m"
ANSI_CLEAR="\033[0m"
SUPPORTED_AUDIO_EXTENSIONS=("*.mp3" "*.ogg" "*.flac" "*.wav" "*.aac" "*.m4a" "*.opus")
MAX_ART_DIMENSION="300"
readonly ANSI_RED ANSI_GREEN ANSI_GREY ANSI_ORANGE ANSI_CLEAR SUPPORTED_AUDIO_EXTENSIONS MAX_ART_DIMENSION
# shellcheck disable=SC2329
function cleanup() {
# Unbind trap to prevent infinite loop
trap - EXIT
# Wait for background job subshell
if [[ -n "$jobsub_pid" ]] && kill -0 "$jobsub_pid" 2>/dev/null; then
wait "$jobsub_pid" 2>/dev/null
fi
# Wait for all background jobs
while [[ -n "$(jobs -rp)" ]]; do
wait 2>/dev/null
done
# Delete temporary directory
[[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]] && rm -rf -- "$TEMP_DIR"
}
# shellcheck disable=SC2329
function on_interrupt() {
# Ignore repeated Ctrl+C
trap '' SIGINT
# Print feedback
echo -e "\n\n${ANSI_ORANGE}WARNING:${ANSI_CLEAR} SIGINT received!"
echo "> Running jobs will be terminated."
echo "> No new jobs will be started."
echo ""
# Set global halt variable
halt_requested=1
# Send SIGUSR1 to job submission background loop
if [[ -n "$jobsub_pid" ]]; then
kill -USR1 "$jobsub_pid" 2>/dev/null
fi
}
function is_valid_mp3() {
local file="$1"
[[ -s "$file" ]] || return 1
ffprobe \
-v error \
-select_streams a:0 \
-show_entries stream=codec_name \
-of default=noprint_wrappers=1:nokey=1 \
"$file" 2>/dev/null |
grep -qx 'mp3'
}
function get_thread_count() {
# Linux --> nproc
# macOS --> sysctl
nproc --all 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo "4"
}
function move_output_file() {
# 'input_audio_file_name' inherited via dynamic scoping since only ever called from 'convert_file'
local source="$1"
local destination="$2"
if ! mv -- "$source" "$destination"; then
send_status "FAILED" "$input_audio_file_name" "WRITE_MOVE_FAIL"
exit 1
fi
}
function convert_file() {
# Return Codes
# > SIGINT_FAIL --> SIGINT-Triggered Conversion Failure
# > TEMP_DIR_CREATE_FAIL --> Temporary Directory Creation Failure
# > OUTPUT_DIR_CREATE_FAIL --> Output Directory Creation Failure
# > MP3_CONVERT_FAIL --> MP3 Conversion Failure
# > WRITE_MOVE_FAIL --> Output MP3 File Write/Move Failure
# > INVALID_MP3_FAIL --> Invalid Output MP3 Failure
# > COVER_EMBED_WARN --> Cover Art Embedding Failure
# > COVER_MOVE_WARN --> Cover Art Replacement Failure
# > COVER_RESIZE_WARN --> Cover Art Resize Failure
# > COVER_NONE_WARN --> No Album Art
# Store input
local input_audio_file_path="$1"
# Manipulate paths
local input_audio_file_name=${input_audio_file_path##*/} # Strip leading path
input_audio_file_name=${input_audio_file_name%.*} # Strip file extension
local relative_input_audio_file_path=${input_audio_file_path#"$I_DIR"/}
local output_audio_file_path="${O_DIR}/${relative_input_audio_file_path%.*}.mp3"
# Create temporary subdirectory
local job_tmp_dir
job_tmp_dir=$(mktemp -d "${TEMP_DIR}/job.XXXXXX") || {
# Temporary Directory Creation Failure
send_status "FAILED" "$input_audio_file_name" "TEMP_DIR_CREATE_FAIL"
exit 1
}
# Remove temporary subdirectory when function returns
trap 'rm -rf -- "$job_tmp_dir"' EXIT
# Catch SIGINT locally for a clean abort
trap 'send_status "FAILED" "$input_audio_file_name" "SIGINT_FAIL"; exit 130' SIGINT
# Skip if output_audio_file_path already exists
if ! is_valid_mp3 "$output_audio_file_path"; then
# Define temporary file paths
local temp_output_audio_file_path="${job_tmp_dir}/nocover.mp3" # Temporary Output MP3 Path
local temp_album_art_path="${job_tmp_dir}/cover.jpg" # Extracted Album/Cover Art
local temp_album_art_processed_path="${job_tmp_dir}/cover_processed.jpg" # Processed Album/Cover Art
# Create the output directory if it does not already exist
if ! mkdir -p -- "$(dirname -- "$output_audio_file_path")"; then
# Output Directory Creation Failure
send_status "FAILED" "$input_audio_file_name" "OUTPUT_DIR_CREATE_FAIL"
exit 1
fi
# Convert input audio file to MP3
# 1. Extreme Quality (Highest Quality VBR)
# 2. Ignore Video (Album/Cover Art)
# 3. Include Track Metadata
# Note: Metadata is mapped from both the global metadata (-map_metadata 0) and the first subtitle stream (-map_metadata 0:s:0)
# Note: '-map_metadata 0' seems to apply to '.mp3' and '.flac' files.
# Note: '-map_metadata 0:s:0' seems to apply to '.ogg' files.
# Note: FFmpeg will automatically pick the applicable metadata input for the given input audio file type
if ! ffmpeg -nostdin -i "$input_audio_file_path" -vn -q:a 0 -map_metadata 0 -map_metadata 0:s:0 -id3v2_version 3 -write_id3v1 1 -write_id3v2 1 -y "$temp_output_audio_file_path" &>/dev/null; then
# MP3 Conversion Failure
send_status "FAILED" "$input_audio_file_name" "MP3_CONVERT_FAIL"
exit 1
fi
# Extract the album/cover art to a temporary file
# Note: Use pixel format 'yuvj420p' with 'full' colour range and 'bt470bg' colour space for compatibility with outdated media players
if ffmpeg -nostdin -i "$input_audio_file_path" -an -update 1 -frames:v 1 -pix_fmt yuvj420p -color_range full -colorspace bt470bg -y "$temp_album_art_path" &>/dev/null; then
# 1. Resize Image (Max 300px Length OR Width) + Preserve Aspect Ratio
# 2. Ensure File Size <500KB
# 3. Remove Image Metadata
# 4. Ensure Standard (Baseline) JPEG Output (i.e. Not Progressive)
if magick "$temp_album_art_path" -resize "${MAX_ART_DIMENSION}x${MAX_ART_DIMENSION}>" -strip -interlace none -define jpeg:extent=500KB "$temp_album_art_processed_path" &>/dev/null; then
if mv -- "$temp_album_art_processed_path" "$temp_album_art_path"; then
# Embed the scaled album/cover art into the output audio file
if ! ffmpeg -nostdin -y -i "$temp_output_audio_file_path" \
-i "$temp_album_art_path" \
-map 0:0 \
-map 1:0 \
-c copy \
-id3v2_version 3 \
-metadata:s:v title="Album cover" \
-metadata:s:v comment="Cover (front)" \
-y "$output_audio_file_path" &>/dev/null; then
# Cover Art Embedding Failure
send_status "WARNING" "$input_audio_file_name" "COVER_EMBED_WARN"
move_output_file "$temp_output_audio_file_path" "$output_audio_file_path"
fi
else
# Cover Art Replacement Failure
send_status "WARNING" "$input_audio_file_name" "COVER_MOVE_WARN"
move_output_file "$temp_output_audio_file_path" "$output_audio_file_path"
fi
else
# Cover Art Resize Failure
send_status "WARNING" "$input_audio_file_name" "COVER_RESIZE_WARN"
move_output_file "$temp_output_audio_file_path" "$output_audio_file_path"
fi
else
# No Album Art
send_status "WARNING" "$input_audio_file_name" "COVER_NONE_WARN"
move_output_file "$temp_output_audio_file_path" "$output_audio_file_path"
fi
# Check for a valid output MP3
if ! is_valid_mp3 "$output_audio_file_path"; then
rm -f -- "$output_audio_file_path"
send_status "FAILED" "$input_audio_file_name" "INVALID_MP3_FAIL"
exit 1
fi
# Return success
send_status "CONVERTED" "$input_audio_file_name"
else
# Return skipped
send_status "SKIPPED" "$input_audio_file_name"
fi
}
function send_status() {
# Examples:
# send_status "CONVERTED" "$relative_input_audio_file_path"
# send_status "SKIPPED" "$relative_input_audio_file_path"
# send_status "WARNING" "$relative_input_audio_file_path" "COVER_NONE_WARN"
# send_status "FAILED" "$relative_input_audio_file_path" "MP3_CONVERT_FAIL"
# send_status "DONE"
local status="$1"
local file="$2"
local reason="${3:-}"
printf '%s\0%s\0%s\0' "$status" "$file" "$reason" >&3
}
function process_fifo_message() {
local status="$1"
local file="$2"
local reason="$3"
case "$status" in
CONVERTED)
# Increment counters
((files_processed++))
((files_converted++))
# Print feedback
printf "[%0*d/%d] ${ANSI_GREEN}Converted${ANSI_CLEAR} '%s'\n" \
"${#total_files_in_directory}" \
"$files_processed" \
"$total_files_in_directory" \
"$file"
;;
SKIPPED)
# Increment counters
((files_processed++))
((files_skipped++))
# Print feedback
printf "[%0*d/%d] Skipped '%s' (Already Exists)\n" \
"${#total_files_in_directory}" \
"$files_processed" \
"$total_files_in_directory" \
"$file"
;;
WARNING)
# Increment counter
((files_warnings++))
# Print feedback
local warning_message
case "$reason" in
COVER_EMBED_WARN)
warning_message="Cover Art Embedding Failure!"
;;
COVER_MOVE_WARN)
warning_message="Cover Art Replacement Failure!"
;;
COVER_RESIZE_WARN)
warning_message="Cover Art Resize Failure!"
;;
COVER_NONE_WARN)
warning_message="No Album Art!"
;;
esac
printf "${ANSI_ORANGE}Warning${ANSI_CLEAR} '%s' (%s)\n" \
"$file" \
"$warning_message"
;;
FAILED)
# Increment counters
((files_processed++))
((files_failed++))
# Print feedback
local failure_message
case "$reason" in
SIGINT_FAIL)
failure_message="SIGINT"
;;
TEMP_DIR_CREATE_FAIL)
failure_message="Temporary Directory Creation Failure!"
;;
OUTPUT_DIR_CREATE_FAIL)
failure_message="Output Directory Creation Failure!"
;;
MP3_CONVERT_FAIL)
failure_message="MP3 Conversion Failure!"
;;
WRITE_MOVE_FAIL)
failure_message="Output MP3 File Write/Move Failure!"
;;
INVALID_MP3_FAIL)
failure_message="Output MP3 Corruption Failure!"
;;
esac
printf "[%0*d/%d] ${ANSI_RED}Failed${ANSI_CLEAR} '%s' (%s)\n" \
"${#total_files_in_directory}" \
"$files_processed" \
"$total_files_in_directory" \
"$file" \
"$failure_message"
;;
esac
}
# Traps
trap cleanup EXIT
trap on_interrupt SIGINT
# Make temporary directory
TEMP_DIR=$(mktemp -d /tmp/rockbox_converter_mt.XXXXXX) || {
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} Failed to create temporary directory!"
exit 1
}
readonly TEMP_DIR
# Check for modern bash as script requires 'Wait and Cooperative Exit' (WCE)
if (( BASH_VERSINFO[0] < 4 )) || (( BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4 )); then
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} This script requires Bash 4.4 or newer for proper signal handling (WCE)." >&2
echo "Current version: ${BASH_VERSION}" >&2
exit 2
fi
# Check if 'ffmpeg' and 'ffprobe' are installed
if ! command -v ffmpeg >/dev/null 2>&1 || ! command -v ffprobe >/dev/null 2>&1; then
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} 'ffmpeg' and/or 'ffprobe' are NOT installed!"
echo "sudo apt install ffmpeg # Debian/Ubuntu"
echo "sudo dnf install ffmpeg # Fedora"
echo "brew install ffmpeg # macOS"
exit 2
fi
# Check if 'magick' (ImageMagick) is installed
if ! command -v magick >/dev/null 2>&1; then
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} ImageMagick is NOT installed!"
echo "sudo apt install imagemagick # Debian/Ubuntu"
echo "sudo dnf install ImageMagick # Fedora"
echo "brew install imagemagick # macOS"
exit 2
fi
# Arguments
I_DIR="$1" # Directory containing input audio files
O_DIR="$2" # Directory within which to store processed/converted audio files
# Ensure both the input and output directories are specified
if [[ -z "$I_DIR" || -z "$O_DIR" ]]; then
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} Insufficient arguments specified!"
echo "Usage: ${0} <input_directory> <output_directory>"
echo "Example: ./rockbox_converter_mt.sh ~/Desktop/IN ~/Desktop/OUT"
exit 3
fi
# Check if the provided input directory path is valid
if [[ ! -d "$I_DIR" ]]; then
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} '${I_DIR}' is not a valid directory!"
exit 4
fi
# Create the output directory if it does not exist
if ! mkdir -p -- "$O_DIR"; then
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} Failed to create output directory '${O_DIR}'!"
exit 5
fi
# Resolve the supplied paths to their absolute forms
if ! I_DIR=$(cd -- "$I_DIR" && pwd -P); then
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} Failed to resolve input directory path!"
exit 6
fi
if ! O_DIR=$(cd -- "$O_DIR" && pwd -P); then
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} Failed to resolve output directory path!"
exit 6
fi
# Check if the input directory is the output directory
if [[ "$I_DIR" == "$O_DIR" ]]; then
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} Input and output directories must be different!"
exit 7
fi
# Check if the output directory is a child of the input directory
case "$O_DIR" in
"$I_DIR"/*)
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} Output directory cannot be inside input directory!"
exit 8
;;
esac
# Recursively capture subdirectories
input_dirs=()
max_dir_path_length=0
function capture_subdirectories() {
# Store parent directory to recurse
local dir="$1"
local item
# Add the current directory to the array
input_dirs+=("$dir")
# Update the maximum directory length
if (( ${#dir} > max_dir_path_length )); then
max_dir_path_length=${#dir}
fi
# Loop through all items in the directory
for item in "$dir"/*; do
# Ignore symlinked directories
if [ -d "$item" ] && [ ! -L "$item" ]; then
# Recursively call the function for subdirectories
item=$(cd -- "$item" && pwd -P)
capture_subdirectories "$item"
fi
done
}
capture_subdirectories "$I_DIR"
# Print the directories that are going to be processed
echo ""
echo -e "${ANSI_ORANGE}DIRECTORIES IDENTIFIED${ANSI_CLEAR}"
printf "%$(( max_dir_path_length + 7 ))s\n" | tr ' ' "="
counter=1
for dir in "${input_dirs[@]}"; do
printf "${ANSI_GREY}%05d) %s${ANSI_CLEAR}\n" "$counter" "$dir"
((counter++))
done
printf "%$(( max_dir_path_length + 7 ))s\n\n" | tr ' ' "="
# Obtain thread count
thread_count=$(get_thread_count)
# Print feedback
echo -e "${ANSI_ORANGE}COMMENCING CONVERSION${ANSI_CLEAR}"
# Initialise global counters
dir_counter=1
total_files_converted=0
total_files_skipped=0
total_files_failed=0
total_files_warnings=0
# Global halt variable
halt_requested=0
# Iterate through all supported audio files in all input subdirectories
for dir in "${input_dirs[@]}"; do
# Cease if SIGINT
(( halt_requested )) && break
# Reset counters
files_processed=0
files_converted=0
files_skipped=0
files_failed=0
files_warnings=0
total_files_in_directory=0
for audio_file_extension in "${SUPPORTED_AUDIO_EXTENSIONS[@]}"; do
for input_audio_file_path in "${dir}"/${audio_file_extension}; do
if [[ -f "$input_audio_file_path" ]]; then
((total_files_in_directory++))
fi
done
done
if (( total_files_in_directory > 0 )); then
# Print feedback
echo -e "${ANSI_GREEN}(${dir_counter}/${#input_dirs[@]})${ANSI_CLEAR} Converting ${total_files_in_directory} supported audio files in '${dir}'"
# Create and open named pipe
FIFO="${TEMP_DIR}/status.${dir_counter}"
mkfifo "$FIFO"
exec 3<> "$FIFO"
# Commence job submission and throttling in background subshell
(
# Update halting variable using SIGUSR1
halt_requested_jobsub=0
trap 'halt_requested_jobsub=1' USR1
for audio_file_extension in "${SUPPORTED_AUDIO_EXTENSIONS[@]}"; do
(( halt_requested_jobsub )) && break
for input_audio_file_path in "${dir}"/${audio_file_extension}; do
(( halt_requested_jobsub )) && break 2
if [[ -s "$input_audio_file_path" ]]; then
convert_file "$input_audio_file_path" &
fi
while (( $(jobs -rp | wc -l) >= thread_count )); do
(( halt_requested_jobsub )) && break 3
wait -n
done
done
done
# Wait for remaining jobs to finish
while [[ -n "$(jobs -rp)" ]]; do
wait 2>/dev/null
done
# Signal finished to main loop
send_status "DONE"
) &
# Store job submission PID
jobsub_pid=$!
# Main shell reads from FIFO
while \
IFS= read -r -d '' status &&
IFS= read -r -d '' file &&
IFS= read -r -d '' reason
do
if [[ "$status" == "DONE" ]]; then
break
fi
process_fifo_message "$status" "$file" "$reason"
done <&3
# Close FIFO
exec 3>&-
# Clear job submission PID
wait "$jobsub_pid" 2>/dev/null
jobsub_pid=""
# Print Directory Summary
echo ""
echo "Directory Summary:"
echo -e " ${ANSI_GREEN}[+]${ANSI_CLEAR} Converted: ${files_converted}"
echo -e " ${ANSI_GREY}[-]${ANSI_CLEAR} Skipped: ${files_skipped}"
echo -e " ${ANSI_RED}[x]${ANSI_CLEAR} Failed: ${files_failed}"
echo -e " ${ANSI_ORANGE}[!]${ANSI_CLEAR} Warnings: ${files_warnings}"
echo ""
# Update totals
total_files_converted=$(( total_files_converted + files_converted ))
total_files_skipped=$(( total_files_skipped + files_skipped ))
total_files_failed=$(( total_files_failed + files_failed ))
total_files_warnings=$(( total_files_warnings + files_warnings ))
else
# Print feedback
echo -e "${ANSI_GREEN}(${dir_counter}/${#input_dirs[@]})${ANSI_CLEAR} Skipping '${dir}' (No supported audio files)"
fi
# Increment counter
((dir_counter++))
done
# Print Final Global Summary
echo -e "${ANSI_ORANGE}===============================================================================${ANSI_CLEAR}"
echo -e "${ANSI_ORANGE} FINISHED! ${ANSI_CLEAR}"
echo -e "${ANSI_ORANGE}===============================================================================${ANSI_CLEAR}"
echo "Global Summary (All Directories):"
echo -e " ${ANSI_GREEN}[+]${ANSI_CLEAR} Total Converted: ${ANSI_GREEN}${total_files_converted}${ANSI_CLEAR} "
echo -e " ${ANSI_GREY}[-]${ANSI_CLEAR} Total Skipped: ${ANSI_GREY}${total_files_skipped}${ANSI_CLEAR} "
echo -e " ${ANSI_RED}[x]${ANSI_CLEAR} Total Failed: ${ANSI_RED}${total_files_failed}${ANSI_CLEAR} "
echo -e " ${ANSI_ORANGE}[!]${ANSI_CLEAR} Total Warnings: ${ANSI_ORANGE}${total_files_warnings}${ANSI_CLEAR} "
echo -e "${ANSI_ORANGE}===============================================================================${ANSI_CLEAR}"
if (( total_files_failed > 0 || halt_requested )); then
exit 1
fi
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment