#!/bin/bash
-# Desc: Converts a directory of mp3s into a single mkv file
+# Desc: Converts a directory of mp3s into a single mkv file with chapters
# Usage: mp3s_to_mkv.sh [DIR in] [DIR out] [BITRATE]
# Example: mp3s_to_mkv.sh ./dir_source ./dir_output 48k
-# Depends: GNU Coretils 8.32 (date)
-# Version: 0.0.1
+# 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.1.0
# plumbing
opus_bitrate="$3"; # e.g. "48k"
file_flist="$dir_tmp"/"$script_rundate"..flist.txt;
file_out_mkv="$dir_out"/output.mkv;
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
} # 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
-build_filelist() {
+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 mp3 files for ffmpeg
+ # Output: file: $file_flist list of mp3 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";
+
- while read -r line; do
- yell "$(printf "file '%s'\n" "${line#./}")";
- printf "file '%s'\n" "${line#./}" >> "$file_flist";
- done < <(find "$dir_in" -type f -iname "*.mp3" | sort);
+ find "$dir_in" -type f -iname "*.mp3" | 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" .mp3)";
+ } >> "$file_chapters";
+
+ chapter_start=$chapter_end
+ done
# Return to original dir
popd || die "FATAL:Directory error:$(pwd)";
-
-}; # build file list for ffmpeg
+}; # build file list and chapters for ffmpeg
ffmpeg_convert() {
# Depends: var dir_tmp
# dir_in
# dir_out
- # Input: file $file_flist list of mp3 files for ffmpeg
+ # Input: file $file_flist list of mp3 files for ffmpeg
+ # file $file_chapters chapters file for ffmpeg
# Change directory to input dir
pushd "$dir_in" || die "FATAL:Directory error:$(pwd)";
# Concatenate mp3 files into a single WAV file
- # # Convert WAV to 48 kbps opus mkv file
- ffmpeg -f concat -safe 0 -i "$file_flist" -c:a pcm_s24le -rf64 auto -f wav - | \
- ffmpeg -i - -c:a libopus -b:a "$opus_bitrate" "$file_out_mkv";
+ # Convert WAV to 48 kbps opus mkv 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 libopus -b:a "$opus_bitrate" \
+ "$file_out_mkv";
# Return to original dir
popd || die "FATAL:Directory error:$(pwd)";
local file
file="$(find "$dir_in" -type f -iname "*.mp3" | sort | head -n1)";
file="$(readlink -f "$file")";
- ffmpeg -i "$file" -an -vcodec copy "$file_albumart";
+ ffmpeg -nostdin -i "$file" -an -vcodec copy "$file_albumart";
}; # save album art from an mp3 to output dir
main() {
check_depends && yell "DEBUG:check_depends OK";
check_plumbing "$@" && yell "DEBUG:check_plumbing OK";
- build_filelist "$@" && yell "DEBUG:build_filelist 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 "$@";
-