| 1 | #!/bin/bash |
| 2 | # Desc: Converts a directory of mp3s into a single mkv file with chapters |
| 3 | # Usage: mp3s_to_mkv.sh [DIR in] [DIR out] [BITRATE] |
| 4 | # Example: mp3s_to_mkv.sh ./dir_source ./dir_output 48k |
| 5 | # Depends: GNU Coreutils 8.32 (date), ffmpeg, ffprobe |
| 6 | # Ref/Attrib: [1] FFmpeg Formats Documentation https://ffmpeg.org/ffmpeg-formats.html#toc-Metadata-2 |
| 7 | # Version: 0.1.0 |
| 8 | |
| 9 | # plumbing |
| 10 | opus_bitrate="$3"; # e.g. "48k" |
| 11 | script_rundate="$(date +%s)"; |
| 12 | dir_tmp="/dev/shm"; |
| 13 | dir_in="$(readlink -f "$1")"; |
| 14 | dir_out="$(readlink -f "$2")"; |
| 15 | file_flist="$dir_tmp"/"$script_rundate"..flist.txt; |
| 16 | file_out_mkv="$dir_out"/output.mkv; |
| 17 | file_albumart="$dir_out"/albumart.png; |
| 18 | file_chapters="$dir_tmp"/"$script_rundate"..chapters.txt; |
| 19 | |
| 20 | yell() { echo "$0: $*" >&2; } # print script path and all args to stderr |
| 21 | die() { yell "$*"; exit 111; } # same as yell() but non-zero exit status |
| 22 | must() { "$@" || die "cannot $*"; } # runs args as command, reports args if command fails |
| 23 | show_usage() { |
| 24 | # Desc: Display script usage information |
| 25 | # Usage: showUsage |
| 26 | # Version 0.0.2 |
| 27 | # Input: none |
| 28 | # Output: stdout |
| 29 | # Depends: GNU-coreutils 8.30 (cat) |
| 30 | cat <<'EOF' |
| 31 | USAGE: |
| 32 | mp3s_to_mkv.sh [DIR in] [DIR out] [BITRATE] |
| 33 | |
| 34 | EXAMPLE: |
| 35 | mp3s_to_mkv.sh ./source_dir ./out_dir 48k |
| 36 | EOF |
| 37 | } # Display information on how to use this script. |
| 38 | check_depends() { |
| 39 | if ! command -v ffmpeg; then show_usage; die "FATAL:Missing ffmpeg."; fi; |
| 40 | if ! command -v ffprobe; then show_usage; die "FATAL:Missing ffprobe."; fi; |
| 41 | }; # check dependencies |
| 42 | check_plumbing() { |
| 43 | if [[ $# -ne 3 ]]; then show_usage; die "FATAL:Invalid arg count:$#"; fi; |
| 44 | if [[ ! -d "$dir_in" ]]; then show_usage; die "FATAL:Not a dir:$dir_in"; fi; |
| 45 | if [[ ! -d "$dir_out" ]]; then mkdir -p "$2"; fi; |
| 46 | }; # check arguments |
| 47 | get_media_length() { |
| 48 | # Use ffprobe to get media container length in seconds (float) |
| 49 | # Usage: get_media_length arg1 |
| 50 | # Input: arg1: path to file |
| 51 | # Output: stdout: seconds (float) |
| 52 | # Depends: ffprobe 4.1.8 |
| 53 | # BK-2020-03: die() |
| 54 | local file_in |
| 55 | file_in="$1"; |
| 56 | if [[ ! -f $file_in ]]; then |
| 57 | die "ERROR:Not a file:$file_in"; |
| 58 | fi; |
| 59 | ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$file_in"; |
| 60 | } # Get media container length in seconds via stdout |
| 61 | build_filelist_and_chapters() { |
| 62 | # Depends: var dir_tmp temporary directory |
| 63 | # var dir_in input dir |
| 64 | # var file_flist path file list |
| 65 | # Output: file: $file_flist list of mp3 files for ffmpeg |
| 66 | # file: $file_chapters chapters file for ffmpeg |
| 67 | |
| 68 | # Change directory to input dir |
| 69 | pushd "$dir_in" || die "FATAL:Directory error:$(pwd)"; |
| 70 | |
| 71 | local chapter_start=0; |
| 72 | local chapter_end; |
| 73 | local duration; |
| 74 | |
| 75 | |
| 76 | # Initialize chapter ffmetadata file. See [1]. |
| 77 | { |
| 78 | echo ";FFMETADATA1"; |
| 79 | } >> "$file_chapters"; |
| 80 | |
| 81 | |
| 82 | find "$dir_in" -type f -iname "*.mp3" | sort | while read -r line; do |
| 83 | local filename="${line#./}"; |
| 84 | yell "$(printf "file '%s'\n" "$filename")"; |
| 85 | printf "file '%s'\n" "$filename" >> "$file_flist"; |
| 86 | |
| 87 | # Get duration of the current file |
| 88 | duration=$(get_media_length "$filename"); |
| 89 | chapter_end=$(echo "$chapter_start + $duration" | bc -l); |
| 90 | |
| 91 | # Write chapter info |
| 92 | { |
| 93 | echo "[CHAPTER]"; |
| 94 | echo "TIMEBASE=1/1000"; |
| 95 | echo "START=$(echo "scale=0; $chapter_start * 1000" | bc -l)"; |
| 96 | echo "END=$(echo "scale=0; $chapter_end * 1000" | bc -l)"; |
| 97 | echo "title=$(basename "$filename" .mp3)"; |
| 98 | } >> "$file_chapters"; |
| 99 | |
| 100 | chapter_start=$chapter_end |
| 101 | done |
| 102 | |
| 103 | # Return to original dir |
| 104 | popd || die "FATAL:Directory error:$(pwd)"; |
| 105 | }; # build file list and chapters for ffmpeg |
| 106 | ffmpeg_convert() { |
| 107 | # Depends: var dir_tmp |
| 108 | # dir_in |
| 109 | # dir_out |
| 110 | # Input: file $file_flist list of mp3 files for ffmpeg |
| 111 | # file $file_chapters chapters file for ffmpeg |
| 112 | |
| 113 | # Change directory to input dir |
| 114 | pushd "$dir_in" || die "FATAL:Directory error:$(pwd)"; |
| 115 | |
| 116 | # Concatenate mp3 files into a single WAV file |
| 117 | # Convert WAV to 48 kbps opus mkv file |
| 118 | ffmpeg -nostdin -f concat -safe 0 -i "$file_flist" -c:a pcm_s24le -rf64 auto -f wav - | \ |
| 119 | ffmpeg -i - -i "$file_chapters" \ |
| 120 | -map_metadata 1 -map_chapters 1 \ |
| 121 | -map 0 -c:a libopus -b:a "$opus_bitrate" \ |
| 122 | "$file_out_mkv"; |
| 123 | |
| 124 | # Return to original dir |
| 125 | popd || die "FATAL:Directory error:$(pwd)"; |
| 126 | }; # convert mp3s to opus mkv via ffmpeg |
| 127 | save_albumart() { |
| 128 | local file |
| 129 | file="$(find "$dir_in" -type f -iname "*.mp3" | sort | head -n1)"; |
| 130 | file="$(readlink -f "$file")"; |
| 131 | ffmpeg -nostdin -i "$file" -an -vcodec copy "$file_albumart"; |
| 132 | }; # save album art from an mp3 to output dir |
| 133 | main() { |
| 134 | check_depends && yell "DEBUG:check_depends OK"; |
| 135 | check_plumbing "$@" && yell "DEBUG:check_plumbing OK"; |
| 136 | build_filelist_and_chapters "$@" && yell "DEBUG:build_filelist_and_chapters OK"; |
| 137 | ffmpeg_convert "$@" && yell "DEBUG:ffmpeg_convert OK"; |
| 138 | save_albumart "$@" && yell "DEBUG:save_albumart OK"; |
| 139 | }; # main program |
| 140 | |
| 141 | main "$@"; |