Skip to content

Instantly share code, notes, and snippets.

@KernelGhost
Last active September 2, 2026 07:31
Show Gist options
  • Select an option

  • Save KernelGhost/7978aad0aff03af5d3f240f8f0188699 to your computer and use it in GitHub Desktop.

Select an option

Save KernelGhost/7978aad0aff03af5d3f240f8f0188699 to your computer and use it in GitHub Desktop.
A 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.sh
# Authors: Rohan Barar
# Revision: 19/11/2025 (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.sh <input_dir> <output_dir>
# Example: ./rockbox_converter.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
shopt -s nullglob
# 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
function cleanup() {
# Delete the temporary directory
[[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]] && rm -rf -- "$TEMP_DIR"
}
function on_interrupt() {
echo -e "${ANSI_ORANGE}WARNING:${ANSI_CLEAR} Terminating early!"
exit 130
}
trap cleanup EXIT
trap on_interrupt SIGINT TERM
# Make temporary directory
TEMP_DIR=$(mktemp -d /tmp/rockbox_converter.XXXXXX) || {
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} Failed to create temporary directory!"
exit 1
}
readonly TEMP_DIR
# Check if 'realpath' is installed
if ! command -v realpath >/dev/null 2>&1; then
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} 'realpath' is not installed!"
echo "sudo apt install coreutils # Debian/Ubuntu"
echo "sudo dnf install coreutils # Fedora"
echo "brew install coreutils # macOS"
exit 2
fi
# Check if 'ffmpeg' is installed
if ! command -v ffmpeg >/dev/null 2>&1; then
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} 'ffmpeg' is 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.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=$(realpath "$I_DIR"); then
echo -e "${ANSI_RED}ERROR:${ANSI_CLEAR} Failed to resolve input directory path!"
exit 6
fi
if ! O_DIR=$(realpath "$O_DIR"); 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"
# 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=$(realpath "$item")
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 "%05d) ${ANSI_GREY}%s${ANSI_CLEAR}\n" "$counter" "$dir"
((counter++))
done
printf "%$(( max_dir_path_length + 7 ))s\n\n" | tr ' ' "="
# Reset counters
dir_counter=1
total_files_converted=0
total_files_skipped=0
# Print feedback
echo -e "${ANSI_ORANGE}COMMENCING CONVERSION${ANSI_CLEAR}"
# Iterate through all supported audio files in all input subdirectories
for dir in "${input_dirs[@]}"; do
# Print formatting
echo "++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++"
# Reset counter
files_processed=0
files_converted=0
files_skipped=0
# Print feedback
echo -e "${ANSI_GREEN}(${dir_counter}/${#input_dirs[@]})${ANSI_CLEAR} Converting audio files in '${dir}'"
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
# Manipulate paths
input_audio_file_name=${input_audio_file_path##*/} # Strip leading path
input_audio_file_name=${input_audio_file_name%.*} # Strip file extension
relative_input_audio_file_path=${input_audio_file_path#"$I_DIR"/}
output_audio_file_path="${O_DIR}/${relative_input_audio_file_path%.*}.mp3"
# Skip if output_audio_file_path already exists
if [[ ! -f "$output_audio_file_path" ]]; then
# Define temporary file paths
temp_output_audio_file_path="${TEMP_DIR}/${input_audio_file_name}_nocover.mp3" # Temporary Output MP3 Path
temp_album_art_path="${TEMP_DIR}/${input_audio_file_name}_cover.jpg" # Extracted Album/Cover Art
temp_album_art_processed_path="${TEMP_DIR}/${input_audio_file_name}_cover_processed.jpg" # Processed Album/Cover Art
# Create the output directory if it does not already exist
mkdir -p "$(dirname "$output_audio_file_path")"
# 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
ffmpeg -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
# 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
ffmpeg -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
# Process the album/cover art if any was extracted
if [[ -f "$temp_album_art_path" ]]; 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)
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
mv "$temp_album_art_processed_path" "$temp_album_art_path"
# Embed the scaled album/cover art into the output audio file
ffmpeg -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
rm "$temp_output_audio_file_path"
rm "$temp_album_art_path"
else
mv "$temp_output_audio_file_path" "$output_audio_file_path"
fi
# Increment counters
((files_processed++))
((files_converted++))
# Print feedback
printf "%03d) Converted '%s'\n" "$files_processed" "$input_audio_file_name"
else
# Increment counters
((files_processed++))
((files_skipped++))
# Print feedback
printf "%03d) Skipped '%s' (Already Exists)\n" "$files_processed" "$input_audio_file_name"
fi
fi
done
done
# Print formatting
echo "++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++"
echo ""
total_files_converted=$(( total_files_converted + files_converted ))
total_files_skipped=$(( total_files_skipped + files_skipped ))
# Increment counter
((dir_counter++))
done
echo -e "${ANSI_ORANGE}FINISHED!${ANSI_CLEAR}"
echo "Converted ${total_files_converted} audio files!"
echo "Skipped ${total_files_skipped} existing audio files!"
@lexiwitch

Copy link
Copy Markdown

I suspect that you could probably skip most of the bulk of this script if you just stuck the ffmpeg lines into a script and then used GNU find. For example, to get image data for all of the image files in my ~/music, I just have a +x file called coverinfo:

#!/bin/bash
magick identify "$@"

and then I run:

find . -depth -type f \( -iname "*.png" -or -iname "*.jpg" -o -iname "*.jpeg" \) -exec ./coverinfo {} \;

which handles directory traversal for me.

If you set IFS to null char properly on the loop, you could just use a single line that does the above with -print0 instead of exec and everything after and it would reduce the amount of total lines (and therefore the overheads of reading and maintaining this script).

@KernelGhost

Copy link
Copy Markdown
Author

A multithreaded version of this script is now available here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment