|
#!/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) |
|
}) |
|
}) |
|
} |
It seems to require a path without spaces in it (even with quote marks) - otherwise, it throws an FFmpeg error.
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 😞