Skip to content

Instantly share code, notes, and snippets.

@kentcdodds
Last active April 1, 2023 12:45
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save kentcdodds/e07f9106c63cc13a75adb0157700eb5b to your computer and use it in GitHub Desktop.
Save kentcdodds/e07f9106c63cc13a75adb0157700eb5b to your computer and use it in GitHub Desktop.
Book Stitcher. Combine multiple mp3 files into a single MP3 file with metadata (for chapters etc.). It's great for audiobooks.

Book Stitcher

This is just something I hacked together to create an audiobook file out of CD audio files (complete with chapter marking metadata!)

Works great when used in combination with https://github.com/kentcdodds/podcastify-dir

npx https://gist.github.com/kentcdodds/e07f9106c63cc13a75adb0157700eb5b ./path-to-sorted-mp3s
path-to-sorted-mp3s
├── 01.mp3
├── 02.mp3
├── 03.mp3
├── 04.mp3
├── art.jpg
└── metadata.json

The art.jpg will be used for the album cover. The metadata.json will be used for ID3 tags. Should be something like this:

{
  "title": "Book Title",
  "artist": "Author name",
  "subtitle": "Some description",
  "albumArtist": "Author name",
  "copyright": "copyright info",
  "date": "1988",
  "year": "1988-01-01",
  "userDefinedText": [
    {
      "description": "book_genre",
      "value": "Children's Audiobooks:Literature & Fiction:Dramatized"
    },
    {
      "description": "narrated_by",
      "value": "BBC"
    },
    {
      "description": "comment",
      "value": "Some description"
    },
    {
      "description": "author",
      "value": "Author Name"
    },
    {
      "description": "asin",
      "value": "49201153"
    }
  ]
}
#!/usr/bin/env node
const path = require('path')
const fs = require('fs')
const mm = require('music-metadata')
const NodeID3 = require('node-id3')
// to convert files from m4a to mp3
// for f in *.m4a; do ffmpeg -i "$f" -codec:v copy -codec:a libmp3lame -q:a 2 newfiles/"${f%.m4a}.mp3"; done
/*
books
├── 01
│   ├── 1-01.mp3
│   ├── 1-02.mp3
│   ├── 1-03.mp3
... etc...
│   ├── art.jpg
│   └── metadata.json
├── 02
│   ├── 1-01.mp3
│   ├── 1-02.mp3
... etc...
*/
createBook(path.resolve(process.argv[2]))
async function createBook(base) {
let specifiedTags
const metadataPath = path.join(base, 'metadata.json')
try {
specifiedTags = require(metadataPath)
} catch (error) {
console.error(`
Make sure you have a metadata.json file at "${metadataPath}" with the audio files:
{
"title": "Book Title",
"artist": "Author name",
"subtitle": "Some description",
"albumArtist": "Author name",
"copyright": "copyright info",
"date": "1988",
"year": "1988-01-01",
"userDefinedText": [
{
"description": "book_genre",
"value": "Children's Audiobooks:Literature & Fiction:Dramatized"
},
{
"description": "narrated_by",
"value": "BBC"
},
{
"description": "comment",
"value": "Some description"
},
{
"description": "author",
"value": "Author Name"
},
{
"description": "asin",
"value": -49201153
}
]
}
`.trim())
throw error
}
const {title} = specifiedTags
const files = fs
.readdirSync(base)
.filter(n => n.endsWith('.mp3'))
.map(n => path.join(base, n))
const metadatas = await Promise.all(
files.map(async filepath => {
const meta = await mm.parseFile(filepath)
return {
filepath,
duration: meta.format.duration,
title: meta.common.title,
}
}),
)
const outputFilepath = path.resolve(`${title}.mp3`)
const glob = path
.join(base, '*.mp3')
.replace(process.cwd(), '')
.replace('/', '')
const outString = JSON.stringify(outputFilepath)
await execShellCommand(
`ffmpeg -y -f concat -safe 0 -i <(for f in ${glob}; do echo "file '$PWD/$f'"; done) -c copy ${outString}`,
)
const chapters = []
let startTimeMs = 0
for (let fileIndex = 0; fileIndex < metadatas.length; fileIndex++) {
const {filepath, duration, title} = metadatas[fileIndex]
const endTimeMs = startTimeMs + duration * 1000
chapters.push({
elementID: `ch${fileIndex}`,
startTimeMs,
endTimeMs,
tags: {title},
})
startTimeMs = endTimeMs
}
const tags = {
title,
album: title,
genre: 'Audiobook',
image: path.join(base, 'art.jpg'),
chapter: chapters,
...specifiedTags,
}
const result = NodeID3.write(tags, outputFilepath)
if (result !== true) {
throw result
}
}
function execShellCommand(cmd) {
const exec = require('child_process').exec
return new Promise((resolve, reject) => {
exec(cmd, {shell: '/bin/zsh'}, (error, stdout, stderr) => {
if (error) return reject(error)
else resolve(stdout ? stdout : stderr)
})
})
}
{
"name": "book-stitcher",
"version": "1.0.0",
"description": "",
"main": "index.js",
"keywords": [],
"author": "Kent C. Dodds <me@kentcdodds.com> (https://kentcdodds.com/)",
"license": "MIT",
"bin": "./bin.js",
"dependencies": {
"music-metadata": "^7.5.0",
"node-id3": "^0.2.1"
}
}
@jefro108
Copy link

jefro108 commented Mar 18, 2022

It seems to require a path without spaces in it (even with quote marks) - otherwise, it throws an FFmpeg error.

[concat @ 0x7f9317804480] No files to concat
/dev/fd/12: Invalid data found when processing input

However, while it concatenated the mp3 files (using a spaceless pathname) and the result contained the metadata and cover art unfortunately it doesn't include a chapter list in the mp3 😞

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