From 9e7d4ab5e7f64b1fd6201a355f17e128c83d4142 Mon Sep 17 00:00:00 2001 From: Quentin King Date: Sat, 3 Jan 2026 04:17:08 -0600 Subject: [PATCH 1/3] feat: Embed AUDIBLE_ASIN metadata in m4b files - Add AUDIBLE_ASIN tag to FFmpeg metadata object - Use -movflags use_metadata_tags to preserve custom tags in mp4/m4b - Fix .m4b extension detection for mp4 format handling - Remove redundant -map_metadata 0 option - Update tests to match new FFmpeg options The mp4 muxer in FFmpeg only writes standard iTunes tags by default. Custom tags like AUDIBLE_ASIN are silently dropped unless use_metadata_tags is specified. Tested and verified with ffprobe and mediainfo on clean m4b files. --- server/utils/ffmpegHelpers.js | 15 ++++++++------- test/server/utils/ffmpegHelpers.test.js | 10 +++++----- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index 80832cc77..ecc86f454 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -295,19 +295,18 @@ module.exports.writeFFMetadataFile = writeFFMetadataFile * @returns {Promise} A promise that resolves if the operation is successful, rejects otherwise. */ async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataFilePath, track, mimeType, progressCB = null, ffmpeg = Ffmpeg(), copyFunc = copyToExisting) { - 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}`)) + const isMp4 = mimeType === 'audio/mp4' || audioFileExt.toLowerCase() === '.m4b' + const isMp3 = mimeType === 'audio/mpeg' + return new Promise((resolve, reject) => { 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_metadata 1', // map metadata tags from metadata file (contains all desired metadata) '-map_chapters 1', // map chapters from metadata file '-c copy' // copy streams ]) @@ -318,7 +317,8 @@ async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataF if (isMp4) { ffmpeg.outputOptions([ - '-f mp4' // force output format to mp4 + '-f mp4', // force output format to mp4 + '-movflags use_metadata_tags' // preserve custom tags like AUDIBLE_ASIN ]) } else if (isMp3) { ffmpeg.outputOptions([ @@ -414,7 +414,8 @@ function getFFMetadataObject(libraryItem, audioFilesLength) { copyright: libraryItem.media.publisher, publisher: libraryItem.media.publisher, // mp3 only TRACKTOTAL: `${audioFilesLength}`, // mp3 only - grouping: libraryItem.media.series?.map((s) => s.name + (s.bookSeries.sequence ? ` #${s.bookSeries.sequence}` : '')).join('; ') + grouping: libraryItem.media.series?.map((s) => s.name + (s.bookSeries.sequence ? ` #${s.bookSeries.sequence}` : '')).join('; '), + AUDIBLE_ASIN: libraryItem.media.asin // Audible ASIN tag for m4b/mp4 files } Object.keys(ffmetadata).forEach((key) => { if (!ffmetadata[key]) { diff --git a/test/server/utils/ffmpegHelpers.test.js b/test/server/utils/ffmpegHelpers.test.js index 95a2c585b..8f2d10bcc 100644 --- a/test/server/utils/ffmpegHelpers.test.js +++ b/test/server/utils/ffmpegHelpers.test.js @@ -120,7 +120,7 @@ describe('addCoverAndMetadataToFile', () => { expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath) expect(ffmpegStub.outputOptions.callCount).to.equal(4) - expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy']) + expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_chapters 1', '-c copy']) expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1']) expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3']) expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover']) @@ -153,7 +153,7 @@ describe('addCoverAndMetadataToFile', () => { expect(ffmpegStub.input.getCall(1).args[0]).to.equal(metadataFilePath) expect(ffmpegStub.outputOptions.callCount).to.equal(4) - expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy']) + expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_chapters 1', '-c copy']) expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1']) expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3']) expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 0:v?']) @@ -195,7 +195,7 @@ describe('addCoverAndMetadataToFile', () => { expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath) expect(ffmpegStub.outputOptions.callCount).to.equal(4) - expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy']) + expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_chapters 1', '-c copy']) expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1']) expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-id3v2_version 3']) expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover']) @@ -227,9 +227,9 @@ describe('addCoverAndMetadataToFile', () => { expect(ffmpegStub.input.getCall(2).args[0]).to.equal(coverFilePath) expect(ffmpegStub.outputOptions.callCount).to.equal(4) - expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_metadata 0', '-map_chapters 1', '-c copy']) + expect(ffmpegStub.outputOptions.getCall(0).args[0]).to.deep.equal(['-map 0:a', '-map_metadata 1', '-map_chapters 1', '-c copy']) expect(ffmpegStub.outputOptions.getCall(1).args[0]).to.deep.equal(['-metadata track=1']) - expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-f mp4']) + expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-f mp4', '-movflags use_metadata_tags']) expect(ffmpegStub.outputOptions.getCall(3).args[0]).to.deep.equal(['-map 2:v', '-disposition:v:0 attached_pic', '-metadata:s:v', 'title=Cover', '-metadata:s:v', 'comment=Cover']) expect(ffmpegStub.output.calledOnce).to.be.true From e7a980d55ab157e1ed52345c1989a41c9bd815f9 Mon Sep 17 00:00:00 2001 From: Quentin King Date: Sun, 4 Jan 2026 09:43:59 -0600 Subject: [PATCH 2/3] Adds extended metadata fields to FFmpeg output Enriches audiobook metadata by adding support for additional fields including abridged/unabridged format, full publication date, ISBN, language, explicit content flags, and subtitle. Improves compatibility with Libation by including both lowercase and uppercase ASIN variants. Sets appropriate iTunes advisory flags to distinguish explicit from clean content. --- server/utils/ffmpegHelpers.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index ecc86f454..ef0447347 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -400,14 +400,24 @@ function escapeFFMetadataValue(value) { * @returns {Object} - The FFmpeg metadata object. */ function getFFMetadataObject(libraryItem, audioFilesLength) { + // Determine abridged/unabridged format string + let format = null + if (libraryItem.media.abridged === true) { + format = 'abridged' + } else if (libraryItem.media.abridged === false) { + format = 'unabridged' + } + const ffmetadata = { title: libraryItem.media.title, artist: libraryItem.media.authorName, album_artist: libraryItem.media.authorName, album: (libraryItem.media.title || '') + (libraryItem.media.subtitle ? `: ${libraryItem.media.subtitle}` : ''), TIT3: libraryItem.media.subtitle, // mp3 only + subtitle: libraryItem.media.subtitle, // m4b/mp4 genre: libraryItem.media.genres?.join('; '), date: libraryItem.media.publishedYear, + RELEASETIME: libraryItem.media.publishedDate, // Full release date YYYY-MM-DD comment: libraryItem.media.description, description: libraryItem.media.description, composer: (libraryItem.media.narrators || []).join(', '), @@ -415,7 +425,13 @@ function getFFMetadataObject(libraryItem, audioFilesLength) { publisher: libraryItem.media.publisher, // mp3 only TRACKTOTAL: `${audioFilesLength}`, // mp3 only grouping: libraryItem.media.series?.map((s) => s.name + (s.bookSeries.sequence ? ` #${s.bookSeries.sequence}` : '')).join('; '), - AUDIBLE_ASIN: libraryItem.media.asin // Audible ASIN tag for m4b/mp4 files + asin: libraryItem.media.asin, // Lowercase for Libation compatibility + AUDIBLE_ASIN: libraryItem.media.asin, // Uppercase for Libation compatibility + ISBN: libraryItem.media.isbn, + LANGUAGE: libraryItem.media.language, + EXPLICIT: libraryItem.media.explicit ? '1' : null, + ITUNESADVISORY: libraryItem.media.explicit ? '1' : '2', // 1 = explicit, 2 = clean + FORMAT: format } Object.keys(ffmetadata).forEach((key) => { if (!ffmetadata[key]) { From cbd63de17d5be105b1be5934af7707fa0d174057 Mon Sep 17 00:00:00 2001 From: Quentin King Date: Sun, 4 Jan 2026 10:11:10 -0600 Subject: [PATCH 3/3] Add series metadata tags to FFmpeg encoding --- server/utils/ffmpegHelpers.js | 45 ++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index ef0447347..47b740883 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -392,6 +392,34 @@ function escapeFFMetadataValue(value) { return value.replace(/([;=\n\\#])/g, '\\$1') } +/** + * Computes the ALBUMSORT tag for proper series ordering. + * Format: + * - No series: "Title" or "Title - Subtitle" + * - In series: "Series Name 001 - Title" (zero-padded sequence) + * + * @param {import('../models/LibraryItem')} libraryItem + * @returns {string} + */ +function computeAlbumSort(libraryItem) { + const title = libraryItem.media.title || '' + const subtitle = libraryItem.media.subtitle + const series = libraryItem.media.series?.[0] + + if (series?.name) { + // In series: "Series Name 001 - Title" + const sequence = series.bookSeries?.sequence || '' + const paddedSequence = sequence ? String(sequence).padStart(3, '0') : '' + return `${series.name} ${paddedSequence} - ${title}`.trim() + } + + // Not in series: "Title" or "Title - Subtitle" + if (subtitle) { + return `${title} - ${subtitle}` + } + return title +} + /** * Retrieves the FFmpeg metadata object for a given library item. * @@ -408,6 +436,11 @@ function getFFMetadataObject(libraryItem, audioFilesLength) { format = 'unabridged' } + // Get first series for series tags (multiple series not widely supported) + const primarySeries = libraryItem.media.series?.[0] + const seriesName = primarySeries?.name + const seriesSequence = primarySeries?.bookSeries?.sequence + const ffmetadata = { title: libraryItem.media.title, artist: libraryItem.media.authorName, @@ -431,7 +464,17 @@ function getFFMetadataObject(libraryItem, audioFilesLength) { LANGUAGE: libraryItem.media.language, EXPLICIT: libraryItem.media.explicit ? '1' : null, ITUNESADVISORY: libraryItem.media.explicit ? '1' : '2', // 1 = explicit, 2 = clean - FORMAT: format + FORMAT: format, + // Series tags (Mp3tag standard) + SERIES: seriesName, + 'SERIES-PART': seriesSequence, + PART: seriesSequence, // Libation uses PART + // M4B-specific series tags + MOVEMENTNAME: seriesName, + MOVEMENT: seriesSequence, + SHOWMOVEMENT: seriesName ? '1' : null, + // Album sort for proper series ordering + ALBUMSORT: computeAlbumSort(libraryItem) } Object.keys(ffmetadata).forEach((key) => { if (!ffmetadata[key]) {