From 5862a40b604f78f32dc176478b96590f3037bd9b Mon Sep 17 00:00:00 2001 From: Steven Baltakatei Sandoval Date: Wed, 5 Nov 2025 16:58:26 +0000 Subject: [PATCH] feat(user/flacs_to_m4b.sh):Add script to convert FLAC to M4B * Note: Adapated from `user/mp3s_to_mkv.sh`. --- user/flacs_to_m4b.sh | 144 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100755 user/flacs_to_m4b.sh diff --git a/user/flacs_to_m4b.sh b/user/flacs_to_m4b.sh new file mode 100755 index 0000000..e50b66c --- /dev/null +++ b/user/flacs_to_m4b.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# Desc: Converts a directory of flacs into a single m4b file with chapters +# Usage: flacs_to_m4b.sh [DIR in] [DIR out] [BITRATE] +# Example: flacs_to_m4b.sh ./dir_source ./dir_output 64k +# Depends: GNU Coreutils 8.32 (date), ffmpeg, ffprobe +# Ref/Attrib: [1] FFmpeg Formats Documentation https://ffmpeg.org/ffmpeg-formats.html#toc-Metadata-2 +# Version: 0.0.2 + +# plumbing +aac_bitrate="$3"; # e.g. "48k" +script_rundate="$(date +%s)"; +dir_tmp="/dev/shm"; +dir_in="$(readlink -f "$1")"; +dir_out="$(readlink -f "$2")"; +file_flist="$dir_tmp"/"$script_rundate"..flist.txt; +file_out_m4b="$dir_out"/output.m4b; +file_albumart="$dir_out"/albumart.png; +file_chapters="$dir_tmp"/"$script_rundate"..chapters.txt; + +yell() { echo "$0: $*" >&2; } # print script path and all args to stderr +die() { yell "$*"; exit 111; } # same as yell() but non-zero exit status +must() { "$@" || die "cannot $*"; } # runs args as command, reports args if command fails +show_usage() { + # Desc: Display script usage information + # Usage: showUsage + # Version 0.0.2 + # Input: none + # Output: stdout + # Depends: GNU-coreutils 8.30 (cat) + cat <<'EOF' + USAGE: + flacs_to_m4b.sh [DIR in] [DIR out] [BITRATE] + + EXAMPLE: + flacs_to_m4b.sh ./source_dir ./out_dir 64k +EOF +} # Display information on how to use this script. +check_depends() { + if ! command -v ffmpeg; then show_usage; die "FATAL:Missing ffmpeg."; fi; + if ! command -v ffprobe; then show_usage; die "FATAL:Missing ffprobe."; fi; +}; # check dependencies +check_plumbing() { + if [[ $# -ne 3 ]]; then show_usage; die "FATAL:Invalid arg count:$#"; fi; + if [[ ! -d "$dir_in" ]]; then show_usage; die "FATAL:Not a dir:$dir_in"; fi; + if [[ ! -d "$dir_out" ]]; then mkdir -p "$2"; fi; +}; # check arguments +get_media_length() { + # Use ffprobe to get media container length in seconds (float) + # Usage: get_media_length arg1 + # Input: arg1: path to file + # Output: stdout: seconds (float) + # Depends: ffprobe 4.1.8 + # BK-2020-03: die() + local file_in; + file_in="$1"; + if [[ ! -f $file_in ]]; then + die "ERROR:Not a file:$file_in"; + fi; + ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_in"; +} # Get media container length in seconds via stdout +build_filelist_and_chapters() { + # Depends: var dir_tmp temporary directory + # var dir_in input dir + # var file_flist path file list + # Output: file: $file_flist list of flac files for ffmpeg + # file: $file_chapters chapters file for ffmpeg + + # Change directory to input dir + pushd "$dir_in" || die "FATAL:Directory error:$(pwd)"; + + local chapter_start=0; + local chapter_end; + local duration; + + + # Initialize chapter ffmetadata file. See [1]. + { + echo ";FFMETADATA1"; + } >> "$file_chapters"; + + + find "$dir_in" -type f -iname "*.flac" | sort | while read -r line; do + local filename="${line#./}"; + yell "$(printf "file '%s'\n" "$filename")"; + printf "file '%s'\n" "$filename" >> "$file_flist"; + + # Get duration of the current file + duration=$(get_media_length "$filename"); + chapter_end=$(echo "$chapter_start + $duration" | bc -l); + + # Write chapter info + { + echo "[CHAPTER]"; + echo "TIMEBASE=1/1000"; + echo "START=$(echo "scale=0; $chapter_start * 1000" | bc -l)"; + echo "END=$(echo "scale=0; $chapter_end * 1000" | bc -l)"; + echo "title=$(basename "$filename" .flac)"; + } >> "$file_chapters"; + + chapter_start=$chapter_end; + done + + # Return to original dir + popd || die "FATAL:Directory error:$(pwd)"; +}; # build file list and chapters for ffmpeg +ffmpeg_convert() { + # Depends: var dir_tmp + # dir_in + # dir_out + # Input: file $file_flist list of flac files for ffmpeg + # file $file_chapters chapters file for ffmpeg + + # Change directory to input dir + pushd "$dir_in" || die "FATAL:Directory error:$(pwd)"; + + # Concatenate flac files into a single WAV file + # Convert WAV to aac m4b file + ffmpeg -nostdin -f concat -safe 0 -i "$file_flist" -c:a pcm_s24le -rf64 auto -f wav - | \ + ffmpeg -i - -i "$file_chapters" \ + -map_metadata 1 -map_chapters 1 \ + -map 0 -c:a aac -b:a "$aac_bitrate" \ + "$file_out_m4b"; + + # Return to original dir + popd || die "FATAL:Directory error:$(pwd)"; +}; # convert flacs to aac m4b via ffmpeg +save_albumart() { + local file + file="$(find "$dir_in" -type f -iname "*.flac" | sort | head -n1)"; + file="$(readlink -f "$file")"; + ffmpeg -nostdin -i "$file" -an -vcodec copy "$file_albumart"; +}; # save album art from an flac to output dir +main() { + check_depends && yell "DEBUG:check_depends OK"; + check_plumbing "$@" && yell "DEBUG:check_plumbing OK"; + build_filelist_and_chapters "$@" && yell "DEBUG:build_filelist_and_chapters OK"; + ffmpeg_convert "$@" && yell "DEBUG:ffmpeg_convert OK"; + save_albumart "$@" && yell "DEBUG:save_albumart OK"; +}; # main program + +main "$@"; + +# Author: Steven Baltakatei Sandoval +# License: GPLv3+ -- 2.39.5