mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-02-28 21:19:42 +00:00
Merge cbd63de17d into 1d0b7e383a
This commit is contained in:
commit
9e3ab13c85
2 changed files with 72 additions and 12 deletions
|
|
@ -295,19 +295,18 @@ module.exports.writeFFMetadataFile = writeFFMetadataFile
|
|||
* @returns {Promise<void>} 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([
|
||||
|
|
@ -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.
|
||||
*
|
||||
|
|
@ -400,21 +428,53 @@ 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'
|
||||
}
|
||||
|
||||
// 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,
|
||||
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(', '),
|
||||
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('; '),
|
||||
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,
|
||||
// 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]) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue