feat(user/mp3s_to_mkv.sh):Enable automatic chapter metadata creation
authorSteven Baltakatei Sandoval <baltakatei@gmail.com>
Wed, 29 May 2024 03:20:13 +0000 (03:20 +0000)
committerSteven Baltakatei Sandoval <baltakatei@gmail.com>
Wed, 29 May 2024 03:20:13 +0000 (03:20 +0000)
user/mp3s_to_mkv.sh

index 144e34bbd8cd7f52a1f111ca52dd0540cef1a208..7a89ed877784d4e626eb47706e74266598bee2ba 100755 (executable)
@@ -1,9 +1,10 @@
 #!/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.2
+# 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"
@@ -14,6 +15,7 @@ dir_out="$(readlink -f "$2")";
 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
@@ -35,43 +37,89 @@ 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
-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
+    # 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 - -c:a libopus -b:a "$opus_bitrate" "$file_out_mkv";
+        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)";
@@ -85,10 +133,9 @@ save_albumart() {
 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 "$@";
-