From fadd14484e53aa14a840a38e2511026522005d04 Mon Sep 17 00:00:00 2001 From: Quentin King Date: Sat, 3 Jan 2026 01:19:05 -0600 Subject: [PATCH 1/3] Add sampleRate and profile extraction for audio files - Extract sampleRate and profile from audio streams in ffprobe output - Store sampleRate and profile in AudioFile objects - Expose sampleRate and profile through API endpoints - Add JSDoc documentation for new fields --- server/models/Book.js | 2 ++ server/objects/files/AudioFile.js | 18 +++++++++++++----- server/scanner/MediaProbeData.js | 2 ++ server/utils/prober.js | 1 + 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/server/models/Book.js b/server/models/Book.js index 96371f3a2..8707e1135 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -61,6 +61,8 @@ const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilter * @property {string} timeBase * @property {number} channels * @property {string} channelLayout + * @property {number} sampleRate + * @property {string} profile * @property {ChapterObject[]} chapters * @property {Object} metaTags * @property {string} mimeType diff --git a/server/objects/files/AudioFile.js b/server/objects/files/AudioFile.js index c0c425ba3..ef05e197c 100644 --- a/server/objects/files/AudioFile.js +++ b/server/objects/files/AudioFile.js @@ -24,6 +24,8 @@ class AudioFile { this.timeBase = null this.channels = null this.channelLayout = null + this.sampleRate = null + this.profile = null this.chapters = [] this.embeddedCoverArt = null @@ -62,6 +64,8 @@ class AudioFile { timeBase: this.timeBase, channels: this.channels, channelLayout: this.channelLayout, + sampleRate: this.sampleRate, + profile: this.profile, chapters: this.chapters, embeddedCoverArt: this.embeddedCoverArt, metaTags: this.metaTags?.toJSON() || {}, @@ -94,6 +98,8 @@ class AudioFile { this.timeBase = data.timeBase this.channels = data.channels this.channelLayout = data.channelLayout + this.sampleRate = data.sampleRate + this.profile = data.profile this.chapters = data.chapters this.embeddedCoverArt = data.embeddedCoverArt || null @@ -130,6 +136,8 @@ class AudioFile { this.timeBase = probeData.timeBase this.channels = probeData.channels this.channelLayout = probeData.channelLayout + this.sampleRate = probeData.sampleRate + this.profile = probeData.profile this.chapters = probeData.chapters || [] this.metaTags = probeData.audioMetaTags this.embeddedCoverArt = probeData.embeddedCoverArt @@ -137,7 +145,7 @@ class AudioFile { syncChapters(updatedChapters) { if (this.chapters.length !== updatedChapters.length) { - this.chapters = updatedChapters.map(ch => ({ ...ch })) + this.chapters = updatedChapters.map((ch) => ({ ...ch })) return true } else if (updatedChapters.length === 0) { if (this.chapters.length > 0) { @@ -154,7 +162,7 @@ class AudioFile { } } if (hasUpdates) { - this.chapters = updatedChapters.map(ch => ({ ...ch })) + this.chapters = updatedChapters.map((ch) => ({ ...ch })) } return hasUpdates } @@ -164,8 +172,8 @@ class AudioFile { } /** - * - * @param {AudioFile} scannedAudioFile + * + * @param {AudioFile} scannedAudioFile * @returns {boolean} true if updates were made */ updateFromScan(scannedAudioFile) { @@ -196,4 +204,4 @@ class AudioFile { return hasUpdated } } -module.exports = AudioFile \ No newline at end of file +module.exports = AudioFile diff --git a/server/scanner/MediaProbeData.js b/server/scanner/MediaProbeData.js index 19480968e..cbe3f8025 100644 --- a/server/scanner/MediaProbeData.js +++ b/server/scanner/MediaProbeData.js @@ -17,6 +17,7 @@ class MediaProbeData { this.channelLayout = null this.channels = null this.sampleRate = null + this.profile = null this.chapters = [] this.audioMetaTags = null @@ -58,6 +59,7 @@ class MediaProbeData { this.channelLayout = this.audioStream.channel_layout this.channels = this.audioStream.channels this.sampleRate = this.audioStream.sample_rate + this.profile = this.audioStream.profile this.chapters = data.chapters || [] this.audioMetaTags = new AudioMetaTags() diff --git a/server/utils/prober.js b/server/utils/prober.js index 40a3b5b5c..0f64dacf7 100644 --- a/server/utils/prober.js +++ b/server/utils/prober.js @@ -114,6 +114,7 @@ function parseMediaStreamInfo(stream, all_streams, total_bit_rate) { info.channels = stream.channels || null info.sample_rate = tryGrabSampleRate(stream) info.channel_layout = tryGrabChannelLayout(stream) + info.profile = stream.profile || null } return info From 194f0189fc89b28e9ab01b7342359abc5f8e3dfd Mon Sep 17 00:00:00 2001 From: Quentin King Date: Sat, 3 Jan 2026 04:17:08 -0600 Subject: [PATCH 2/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 47f6f4e18a063a488612d3a793f5ffb552477f33 Mon Sep 17 00:00:00 2001 From: Quentin King Date: Sat, 3 Jan 2026 04:23:28 -0600 Subject: [PATCH 3/3] Revert ASIN changes - moved to separate PR #4959 --- server/utils/ffmpegHelpers.js | 15 +++++++-------- test/server/utils/ffmpegHelpers.test.js | 10 +++++----- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index ecc86f454..80832cc77 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -295,18 +295,19 @@ 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 (contains all desired metadata) + '-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 ]) @@ -317,8 +318,7 @@ async function addCoverAndMetadataToFile(audioFilePath, coverFilePath, metadataF if (isMp4) { ffmpeg.outputOptions([ - '-f mp4', // force output format to mp4 - '-movflags use_metadata_tags' // preserve custom tags like AUDIBLE_ASIN + '-f mp4' // force output format to mp4 ]) } else if (isMp3) { ffmpeg.outputOptions([ @@ -414,8 +414,7 @@ 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('; '), - AUDIBLE_ASIN: libraryItem.media.asin // Audible ASIN tag for m4b/mp4 files + grouping: libraryItem.media.series?.map((s) => s.name + (s.bookSeries.sequence ? ` #${s.bookSeries.sequence}` : '')).join('; ') } 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 8f2d10bcc..95a2c585b 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_chapters 1', '-c copy']) + 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(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_chapters 1', '-c copy']) + 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(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_chapters 1', '-c copy']) + 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(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_chapters 1', '-c copy']) + 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(1).args[0]).to.deep.equal(['-metadata track=1']) - expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-f mp4', '-movflags use_metadata_tags']) + expect(ffmpegStub.outputOptions.getCall(2).args[0]).to.deep.equal(['-f mp4']) 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