Skip to content

Instantly share code, notes, and snippets.

@butuzov
Created August 10, 2018 06:07
Show Gist options
  • Star 42 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • Save butuzov/fa7d456ebc3ec0493c0a10b73800bf42 to your computer and use it in GitHub Desktop.
Save butuzov/fa7d456ebc3ec0493c0a10b73800bf42 to your computer and use it in GitHub Desktop.
Convert mp3's to m4b using `ffmpeg`

Let's imagine we have a lot of mp3 files ( forexample one of the pluralsite courses converted to mp3 ).

URL=https://www.pluralsight.com/courses/run-effective-meetings
PASS=pass
USER=user
OUTPUT="%(playlist_index)s. %(title)s-%(id)s.%(ext)s"
youtube-dl --username $USER --password $PASS -o $OUTPUT --extract-audio --audio-format mp3 $URL

Once you have a list of files we can start converting it first by combining all mp3 into one, and then converting it to m4a/m4b format.

  ls | grep "mp3" | awk '{printf "file |%s|\n", $0}' | sed -e "s/|/\'/g" > list.txt \
  && ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp3 \
  && ffmpeg -i output.mp3 output.m4a \
  && mv output.m4a output.m4b
@butuzov
Copy link
Author

butuzov commented Feb 7, 2019

Updated version that used as command

#!/usr/bin/env bash
 
abook() {
    local DIR="${1}"

    if [[ ! -d $DIR || -z $1 ]]; then
        DIR=$(pwd)
    fi

    # generating random name
    local NAME=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 6 | head -n 1)

    # generating book
    ls -1 "${DIR}/"*.mp3 | awk  '{printf "file |%s|\n", $0}' | \
        sed -e "s/|/\'/g" > "${DIR}/${NAME}.txt" \
        && ffmpeg -f concat -safe 0 -i "${DIR}/${NAME}.txt" -c copy "${DIR}/${NAME}.mp3" \
        && ffmpeg -i "${DIR}/${NAME}.mp3" "${DIR}/${NAME}.m4a" \
        && mv "${DIR}/${NAME}.m4a" "${DIR}/$(basename "${DIR}").m4b"

    # Cleanup
    unlink "${DIR}/${NAME}.txt"
    unlink "${DIR}/${NAME}.mp3"
}

abook "$1"

@butuzov
Copy link
Author

butuzov commented Mar 8, 2019

@Samakus1
Copy link

Amazing stuffs here

@johnmaguire
Copy link

For multi-disc directories, you can use the following:

find . | grep "mp3" | awk '{printf "file |%s|\n", $0}' | sed -e "s/|/\'/g" > list.txt \
  && ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp3 \
  && ffmpeg -i output.mp3 output.m4a \
  && mv output.m4a output.m4b

@LinusU
Copy link

LinusU commented Mar 30, 2024

I wanted to convert multiple mp3 files to a single m4b, and ran into a lot of problems. What finally worked for me in the end was something like this:

Step 1, turn each chapter into its own m4b file. In my case the files where named e.g. "Ch 1a", "Ch 1b", ..., "Ch 2a", etc. So for each chapter I created a input file matching the ffmpeg "concat" format, and then ran the following ffmpeg invocation in order to turn every handful of mp3s to a single m4b. The -vn flag is used to strip away the album art (I'll be adding it back in the last step, but it had the wrong dimensions here which made ffmpeg refuse to work with it).

ffmpeg -f concat -safe 0 -i "book-001.txt" -vn "book-001.m4b"

Step 2, create a metadata file that describes every chapter. I did this by running ffprobe on all of the output files from the previous step, and calculated the correct start and end positions for every chapter. This file is saved as book.meta. I also have the album art saved as book.jpg.

ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "book-001.m4b"

Step 3, stitch it all together. I created a final "concat" file (book.txt) which contains one line per created m4b file from step 1. Then used the following ffmpeg invocation to turn it into a single file:

ffmpeg -f concat -safe 0 -i "book.txt" -i "book.meta" -i "book.jpg" -map 0:a -map_metadata 1 -map 2:v -disposition:v:0 attached_pic -c copy -movflags +faststart "book.m4b"

In order to actually do this, I used the following small Node.js script:

const child_process = require('node:child_process')
const fs = require('node:fs')

const files = process.argv.slice(2)
const chapters = []

for (const file of files) {
  if (file.endsWith('Intro.mp3')) {
    chapters.push({ title: 'Intro', files: [file] })
    continue
  }

  if (file.endsWith('a.mp3')) {
    chapters.push({ title: 'Chapter ' + file.match(/Ch (\d+)/)?.[1], files: [file] })
    continue
  }

  if (file.endsWith('The End.mp3')) {
    chapters.push({ title: 'The End', files: [file] })
    continue
  }

  chapters.at(-1).files.push(file)
}

let metadata = ';FFMETADATA1\n'
metadata += 'genre=Thriller\n'
metadata += 'title=Book Name\n'
metadata += 'album=Book Name\n'
metadata += 'artist=Book Author\n'
metadata += 'composer=Book Narrator\n'

let pos = 0
let chapterFiles = []

for (const [idx, chapter] of chapters.entries()) {
  let txt = `book-${String(idx).padStart(3, '0')}.txt`
  let m4b = `book-${String(idx).padStart(3, '0')}.m4b`

  fs.writeFileSync(txt, chapter.files.map(file => `file '${file}'`).join('\n'))
  child_process.execSync(`ffmpeg -f concat -safe 0 -i "${txt}" -vn "${m4b}"`)

  const duration = Number(child_process.execSync(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${m4b}"`, { encoding: 'utf8' }).trim()) * 1_000_000

  metadata += `[CHAPTER]\n`
  metadata += `TIMEBASE=1/1000000\n`
  metadata += `START=${pos}\n`
  metadata += `END=${pos + duration}\n`
  metadata += `title=${chapter.title}\n`

  pos += duration
  chapterFiles.push(m4b)
}

fs.writeFileSync('book.txt', chapterFiles.map(file => `file '${file}'`).join('\n'))
fs.writeFileSync('book.meta', metadata)

child_process.execSync(`ffmpeg -f concat -safe 0 -i "book.txt" -i "book.meta" -i "book.jpg" -map 0:a -map_metadata 1 -map 2:v -disposition:v:0 attached_pic -c copy -movflags +faststart "book.m4b"`)
console.log('book.m4b')

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment