-
-
Save jasonbot/961f1fd947afa3b0ae75c7f8d94fff00 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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" | |
| # Rockbox on iPod Classic 6G fails to play 48kHz (and other non-44.1kHz) MP3s, | |
| # so everything is resampled to 44.1kHz during conversion. | |
| TARGET_SAMPLE_RATE="44100" | |
| readonly ANSI_RED ANSI_GREEN ANSI_GREY ANSI_ORANGE ANSI_CLEAR SUPPORTED_AUDIO_EXTENSIONS MAX_ART_DIMENSION TARGET_SAMPLE_RATE | |
| 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 | |
| # 4. Resample to 44.1kHz (TARGET_SAMPLE_RATE) for Rockbox/iPod compatibility | |
| # 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 -ar "${TARGET_SAMPLE_RATE}" -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!" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment