Merge pull request #3111 from mikiher/tone-replacement

Replace tone with ffmpeg for metadata and cover embedding
This commit is contained in:
advplyr 2024-07-06 16:03:17 -05:00 committed by GitHub
commit 9a4c5a16ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 542 additions and 448 deletions

View file

@ -1,9 +1,11 @@
const axios = require('axios')
const Ffmpeg = require('../libs/fluentFfmpeg')
const fs = require('../libs/fsExtra')
const os = require('os')
const Path = require('path')
const Logger = require('../Logger')
const { filePathToPOSIX } = require('./fileUtils')
const LibraryItem = require('../objects/LibraryItem')
function escapeSingleQuotes(path) {
// return path.replace(/'/g, '\'\\\'\'')
@ -184,3 +186,183 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
ffmpeg.run()
})
}
/**
* Generates ffmetadata file content from the provided metadata object and chapters array.
* @param {Object} metadata - The input metadata object.
* @param {Array|null} chapters - An array of chapter objects.
* @returns {string} - The ffmetadata file content.
*/
function generateFFMetadata(metadata, chapters) {
let ffmetadataContent = ';FFMETADATA1\n'
// Add global metadata
for (const key in metadata) {
if (metadata[key]) {
ffmetadataContent += `${key}=${escapeFFMetadataValue(metadata[key])}\n`
}
}
// Add chapters
if (chapters) {
chapters.forEach((chapter) => {
ffmetadataContent += '\n[CHAPTER]\n'
ffmetadataContent += `TIMEBASE=1/1000\n`
ffmetadataContent += `START=${Math.floor(chapter.start * 1000)}\n`
ffmetadataContent += `END=${Math.floor(chapter.end * 1000)}\n`
if (chapter.title) {
ffmetadataContent += `title=${escapeFFMetadataValue(chapter.title)}\n`
}
})
}
return ffmetadataContent
}
module.exports.generateFFMetadata = generateFFMetadata
/**
* Writes FFmpeg metadata file with the given metadata and chapters.
*
* @param {Object} metadata - The metadata object.
* @param {Array} chapters - The array of chapter objects.
* @param {string} ffmetadataPath - The path to the FFmpeg metadata file.
* @returns {Promise<boolean>} - A promise that resolves to true if the file was written successfully, false otherwise.
*/
async function writeFFMetadataFile(metadata, chapters, ffmetadataPath) {
try {
await fs.writeFile(ffmetadataPath, generateFFMetadata(metadata, chapters))
Logger.debug(`[ffmpegHelpers] Wrote ${ffmetadataPath}`)
return true
} catch (error) {
Logger.error(`[ffmpegHelpers] Write ${ffmetadataPath} failed`, error)
return false
}
}
module.exports.writeFFMetadataFile = writeFFMetadataFile
/**
* Adds an ffmetadata and optionally a cover image to an audio file using fluent-ffmpeg.
*
* @param {string} audioFilePath - Path to the input audio file.
* @param {string|null} coverFilePath - Path to the cover image file.
* @param {string} metadataFilePath - Path to the ffmetadata file.
* @param {number} track - The track number to embed in the audio file.
* @param {string} mimeType - The MIME type of the audio file.
* @param {Ffmpeg} ffmpeg - The Ffmpeg instance to use (optional). Used for dependency injection in tests.
* @returns {Promise<boolean>} A promise that resolves to true if the operation is successful, false otherwise.
*/
async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, ffmpeg = Ffmpeg()) {
const isMp4 = mimeType === 'audio/mp4'
const isMp3 = mimeType === 'audio/mpeg'
const audioFileDir = Path.dirname(audioFilePath)
const audioFileExt = Path.extname(audioFilePath)
const audioFileBaseName = Path.basename(audioFilePath, audioFileExt)
const tempFilePath = filePathToPOSIX(Path.join(audioFileDir, `${audioFileBaseName}.tmp${audioFileExt}`))
return new Promise((resolve) => {
ffmpeg.input(audioFilePath).input(metadataFilePath).outputOptions([
'-map 0:a', // map audio stream from input file
'-map_metadata 1', // map metadata tags from metadata file first
'-map_metadata 0', // add additional metadata tags from input file
'-map_chapters 1', // map chapters from metadata file
'-c copy' // copy streams
])
if (track && !isNaN(track)) {
ffmpeg.outputOptions(['-metadata track=' + track])
}
if (isMp4) {
ffmpeg.outputOptions([
'-f mp4' // force output format to mp4
])
} else if (isMp3) {
ffmpeg.outputOptions([
'-id3v2_version 3' // set ID3v2 version to 3
])
}
if (coverFilePath) {
ffmpeg.input(coverFilePath).outputOptions([
'-map 2:v', // map video stream from cover image file
'-disposition:v:0 attached_pic', // set cover image as attached picture
'-metadata:s:v',
'title=Cover', // add title metadata to cover image stream
'-metadata:s:v',
'comment=Cover' // add comment metadata to cover image stream
])
} else {
ffmpeg.outputOptions([
'-map 0:v?' // retain video stream from input file if exists
])
}
ffmpeg
.output(tempFilePath)
.on('start', function (commandLine) {
Logger.debug('[ffmpegHelpers] Spawned Ffmpeg with command: ' + commandLine)
})
.on('end', (stdout, stderr) => {
Logger.debug('[ffmpegHelpers] ffmpeg stdout:', stdout)
Logger.debug('[ffmpegHelpers] ffmpeg stderr:', stderr)
fs.copyFileSync(tempFilePath, audioFilePath)
fs.unlinkSync(tempFilePath)
resolve(true)
})
.on('error', (err, stdout, stderr) => {
Logger.error('Error adding cover image and metadata:', err)
Logger.error('ffmpeg stdout:', stdout)
Logger.error('ffmpeg stderr:', stderr)
resolve(false)
})
ffmpeg.run()
})
}
module.exports.addCoverAndMetadataToFile = addCoverAndMetadataToFile
function escapeFFMetadataValue(value) {
return value.replace(/([;=\n\\#])/g, '\\$1')
}
/**
* Retrieves the FFmpeg metadata object for a given library item.
*
* @param {LibraryItem} libraryItem - The library item containing the media metadata.
* @param {number} audioFilesLength - The length of the audio files.
* @returns {Object} - The FFmpeg metadata object.
*/
function getFFMetadataObject(libraryItem, audioFilesLength) {
const metadata = libraryItem.media.metadata
const ffmetadata = {
title: metadata.title,
artist: metadata.authorName,
album_artist: metadata.authorName,
album: (metadata.title || '') + (metadata.subtitle ? `: ${metadata.subtitle}` : ''),
TIT3: metadata.subtitle, // mp3 only
genre: metadata.genres?.join('; '),
date: metadata.publishedYear,
comment: metadata.description,
description: metadata.description,
composer: metadata.narratorName,
copyright: metadata.publisher,
publisher: metadata.publisher, // mp3 only
TRACKTOTAL: `${audioFilesLength}`, // mp3 only
grouping: metadata.series?.map((s) => s.name + (s.sequence ? ` #${s.sequence}` : '')).join(', ')
}
Object.keys(ffmetadata).forEach((key) => {
if (!ffmetadata[key]) {
delete ffmetadata[key]
}
})
return ffmetadata
}
module.exports.getFFMetadataObject = getFFMetadataObject

View file

@ -1,113 +0,0 @@
const tone = require('node-tone')
const fs = require('../libs/fsExtra')
const Logger = require('../Logger')
function getToneMetadataObject(libraryItem, chapters, trackTotal, mimeType = null) {
const bookMetadata = libraryItem.media.metadata
const coverPath = libraryItem.media.coverPath
const isMp4 = mimeType === 'audio/mp4'
const isMp3 = mimeType === 'audio/mpeg'
const metadataObject = {
'album': bookMetadata.title || '',
'title': bookMetadata.title || '',
'trackTotal': trackTotal,
'additionalFields': {}
}
if (bookMetadata.subtitle) {
metadataObject['subtitle'] = bookMetadata.subtitle
}
if (bookMetadata.authorName) {
metadataObject['artist'] = bookMetadata.authorName
metadataObject['albumArtist'] = bookMetadata.authorName
}
if (bookMetadata.description) {
metadataObject['comment'] = bookMetadata.description
metadataObject['description'] = bookMetadata.description
}
if (bookMetadata.narratorName) {
metadataObject['narrator'] = bookMetadata.narratorName
metadataObject['composer'] = bookMetadata.narratorName
}
if (bookMetadata.firstSeriesName) {
if (!isMp3) {
metadataObject.additionalFields['----:com.pilabor.tone:SERIES'] = bookMetadata.firstSeriesName
}
metadataObject['movementName'] = bookMetadata.firstSeriesName
}
if (bookMetadata.firstSeriesSequence) {
// Non-mp3
if (!isMp3) {
metadataObject.additionalFields['----:com.pilabor.tone:PART'] = bookMetadata.firstSeriesSequence
}
// MP3 Files with non-integer sequence
const isNonIntegerSequence = String(bookMetadata.firstSeriesSequence).includes('.') || isNaN(bookMetadata.firstSeriesSequence)
if (isMp3 && isNonIntegerSequence) {
metadataObject.additionalFields['PART'] = bookMetadata.firstSeriesSequence
}
if (!isNonIntegerSequence) {
metadataObject['movement'] = bookMetadata.firstSeriesSequence
}
}
if (bookMetadata.genres.length) {
metadataObject['genre'] = bookMetadata.genres.join('/')
}
if (bookMetadata.publisher) {
metadataObject['publisher'] = bookMetadata.publisher
}
if (bookMetadata.asin) {
if (!isMp3) {
metadataObject.additionalFields['----:com.pilabor.tone:AUDIBLE_ASIN'] = bookMetadata.asin
}
if (!isMp4) {
metadataObject.additionalFields['asin'] = bookMetadata.asin
}
}
if (bookMetadata.isbn) {
metadataObject.additionalFields['isbn'] = bookMetadata.isbn
}
if (coverPath) {
metadataObject['coverFile'] = coverPath
}
if (parsePublishedYear(bookMetadata.publishedYear)) {
metadataObject['publishingDate'] = parsePublishedYear(bookMetadata.publishedYear)
}
if (chapters && chapters.length > 0) {
let metadataChapters = []
for (const chapter of chapters) {
metadataChapters.push({
start: Math.round(chapter.start * 1000),
length: Math.round((chapter.end - chapter.start) * 1000),
title: chapter.title,
})
}
metadataObject['chapters'] = metadataChapters
}
return metadataObject
}
module.exports.getToneMetadataObject = getToneMetadataObject
module.exports.writeToneMetadataJsonFile = (libraryItem, chapters, filePath, trackTotal, mimeType) => {
const metadataObject = getToneMetadataObject(libraryItem, chapters, trackTotal, mimeType)
return fs.writeFile(filePath, JSON.stringify({ meta: metadataObject }, null, 2))
}
module.exports.tagAudioFile = (filePath, payload) => {
if (process.env.TONE_PATH) {
tone.TONE_PATH = process.env.TONE_PATH
}
return tone.tag(filePath, payload).then((data) => {
return true
}).catch((error) => {
Logger.error(`[toneHelpers] tagAudioFile: Failed for "${filePath}"`, error)
return false
})
}
function parsePublishedYear(publishedYear) {
if (isNaN(publishedYear) || !publishedYear || Number(publishedYear) <= 0) return null
return `01/01/${publishedYear}`
}

View file

@ -1,173 +0,0 @@
const tone = require('node-tone')
const MediaProbeData = require('../scanner/MediaProbeData')
const Logger = require('../Logger')
/*
Sample dump from tone
{
"audio": {
"bitrate": 17,
"format": "MPEG-4 Part 14",
"formatShort": "MPEG-4",
"sampleRate": 44100.0,
"duration": 209284.0,
"channels": {
"count": 2,
"description": "Stereo (2/0.0)"
},
"frames": {
"offset": 42168,
"length": 446932
"metaFormat": [
"mp4"
]
},
"meta": {
"album": "node-tone",
"albumArtist": "advplyr",
"artist": "advplyr",
"composer": "Composer 5",
"comment": "testing out tone metadata",
"encodingTool": "audiobookshelf",
"genre": "abs",
"itunesCompilation": "no",
"itunesMediaType": "audiobook",
"itunesPlayGap": "noGap",
"narrator": "Narrator 5",
"recordingDate": "2022-09-10T00:00:00",
"title": "Test 5",
"trackNumber": 5,
"chapters": [
{
"start": 0,
"length": 500,
"title": "chapter 1"
},
{
"start": 500,
"length": 500,
"title": "chapter 2"
},
{
"start": 1000,
"length": 208284,
"title": "chapter 3"
}
],
"embeddedPictures": [
{
"code": 14,
"mimetype": "image/png",
"data": "..."
},
"additionalFields": {
"test": "Test 5"
}
},
"file": {
"size": 530793,
"created": "2022-09-10T13:32:51.1942586-05:00",
"modified": "2022-09-10T14:09:19.366071-05:00",
"accessed": "2022-09-11T13:00:56.5097533-05:00",
"path": "C:\\Users\\Coop\\Documents\\NodeProjects\\node-tone\\samples",
"name": "m4b.m4b"
}
*/
function bitrateKilobitToBit(bitrate) {
if (isNaN(bitrate) || !bitrate) return 0
return Number(bitrate) * 1000
}
function msToSeconds(ms) {
if (isNaN(ms) || !ms) return 0
return Number(ms) / 1000
}
function parseProbeDump(dumpPayload) {
const audioMetadata = dumpPayload.audio
const audioChannels = audioMetadata.channels || {}
const audio_stream = {
bit_rate: bitrateKilobitToBit(audioMetadata.bitrate), // tone uses Kbps but ffprobe uses bps so convert to bits
codec: null,
time_base: null,
language: null,
channel_layout: audioChannels.description || null,
channels: audioChannels.count || null,
sample_rate: audioMetadata.sampleRate || null
}
let chapterIndex = 0
const chapters = (dumpPayload.meta.chapters || []).map(chap => {
return {
id: chapterIndex++,
start: msToSeconds(chap.start),
end: msToSeconds(chap.start + chap.length),
title: chap.title || ''
}
})
var video_stream = null
if (dumpPayload.meta.embeddedPictures && dumpPayload.meta.embeddedPictures.length) {
const mimetype = dumpPayload.meta.embeddedPictures[0].mimetype
video_stream = {
codec: mimetype === 'image/png' ? 'png' : 'jpeg'
}
}
const tags = { ...dumpPayload.meta }
delete tags.chapters
delete tags.embeddedPictures
const fileMetadata = dumpPayload.file
var sizeBytes = !isNaN(fileMetadata.size) ? Number(fileMetadata.size) : null
var sizeMb = sizeBytes !== null ? Number((sizeBytes / (1024 * 1024)).toFixed(2)) : null
return {
format: audioMetadata.format || 'Unknown',
duration: msToSeconds(audioMetadata.duration),
size: sizeBytes,
sizeMb,
bit_rate: audio_stream.bit_rate,
audio_stream,
video_stream,
chapters,
tags
}
}
module.exports.probe = (filepath, verbose = false) => {
if (process.env.TONE_PATH) {
tone.TONE_PATH = process.env.TONE_PATH
}
return tone.dump(filepath).then((dumpPayload) => {
if (verbose) {
Logger.debug(`[toneProber] dump for file "${filepath}"`, dumpPayload)
}
const rawProbeData = parseProbeDump(dumpPayload)
const probeData = new MediaProbeData()
probeData.setDataFromTone(rawProbeData)
return probeData
}).catch((error) => {
Logger.error(`[toneProber] Failed to probe file at path "${filepath}"`, error)
return {
error
}
})
}
module.exports.rawProbe = (filepath) => {
if (process.env.TONE_PATH) {
tone.TONE_PATH = process.env.TONE_PATH
}
return tone.dump(filepath).then((dumpPayload) => {
return dumpPayload
}).catch((error) => {
Logger.error(`[toneProber] Failed to probe file at path "${filepath}"`, error)
return {
error
}
})
}