Skip to content

Instantly share code, notes, and snippets.

@KernelGhost
Last active August 24, 2026 23:18
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!"
@koriwi

koriwi commented Aug 28, 2025

Copy link
Copy Markdown

Nice work. Works very well.

@KernelGhost

Copy link
Copy Markdown
Author

@koriwi thanks ☺️

@mituzora

mituzora commented Nov 17, 2025

Copy link
Copy Markdown

I'm doing a test run of this script right now to see how well it works. So far, it's nearly exactly what I was looking for. Only caveats I see so far is:

It reruns the conversion, even if the file exists
(I'm not sure because this hasn't gotten far with my testing, but I assume at least) if the file is already lossy, I don't want it to convert. I'd rather just play it direct.
Finally, more of a nitpick that I can easily look into fixing. I prefer CBR 320 over VBR, but this is a small gripe, and I'm not gonna complain about it.

I already modified it for my own use case to skip conversion if the file already exists on the target, so incremental syncs won't take forever to do. The next step I'm looking into is detecting if a file is already flac. If it's already flac, then go ahead and do the conversion, otherwise, skip the conversion and just copy the file.

Thanks again for your very helpful script!

EDIT: in the sake of contributions, pasted below is the diff:

0a1
> 
150,154c151
<                 # skips if output_audio_file_path exists
<                 if [ -e "$output_audio_file_path" ]; then
<                         printf "%03d) Exists -- Skipping '%s'\n" "$file_counter" "$input_audio_file_name"
<                         ((file_counter++))
<                 else
---
> 
202,203d198
<                         # adds to output list
<                         echo $output_audio_file_path >> ${O_DIR}/converted_list
210d204
<         fi

@KernelGhost

Copy link
Copy Markdown
Author

@mituzora I’m really glad to hear the script was useful. The gist has now been updated so the script skips files that have already been converted. Thanks again for taking the time to test it and share your suggestions.

@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).

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