-
-
Save KernelGhost/7978aad0aff03af5d3f240f8f0188699 to your computer and use it in GitHub Desktop.
| #!/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 thanks
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
@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.
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).
Nice work. Works very well.