mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-09-10 17:58:02 +02:00
Add podcast support to metadata embedding tools
This commit is contained in:
parent
fd4932cdbb
commit
69b6c0c79a
@ -168,7 +168,7 @@ export default {
|
||||
}
|
||||
]
|
||||
|
||||
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
|
||||
if (this.selectedMediaItemsArePlayable) {
|
||||
options.push({
|
||||
text: this.$strings.ButtonQuickEmbedMetadata,
|
||||
action: 'quick-embed'
|
||||
|
@ -115,7 +115,6 @@ export default {
|
||||
id: 'tools',
|
||||
title: this.$strings.HeaderTools,
|
||||
component: 'modals-item-tabs-tools',
|
||||
mediaType: 'book',
|
||||
admin: true
|
||||
},
|
||||
{
|
||||
|
@ -20,7 +20,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Embed Metadata -->
|
||||
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
|
||||
<div v-if="hasMediaToEmbed" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p>
|
||||
@ -28,7 +28,7 @@
|
||||
</div>
|
||||
<div class="grow" />
|
||||
<div>
|
||||
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=embed`" class="flex items-center"
|
||||
<ui-btn v-if="!isPodcast" :to="`/audiobook/${libraryItemId}/manage?tool=embed`" class="flex items-center"
|
||||
>{{ $strings.ButtonOpenManager }}
|
||||
<span class="material-symbols text-lg ml-2">launch</span>
|
||||
</ui-btn>
|
||||
@ -48,7 +48,7 @@
|
||||
</widgets-alert>
|
||||
</div>
|
||||
|
||||
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
|
||||
<p v-if="!hasMediaToEmbed" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -74,6 +74,18 @@ export default {
|
||||
mediaTracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
isPodcast() {
|
||||
return (this.libraryItem?.mediaType || '') === 'podcast'
|
||||
},
|
||||
podcastEpisodes() {
|
||||
return this.media.episodes || []
|
||||
},
|
||||
hasMediaToEmbed() {
|
||||
if (this.isPodcast) {
|
||||
return this.podcastEpisodes.some((ep) => ep && ep.audioFile)
|
||||
}
|
||||
return this.mediaTracks.length > 0
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
|
@ -82,7 +82,7 @@ class ToolsController {
|
||||
* @param {Response} res
|
||||
*/
|
||||
async embedAudioFileMetadata(req, res) {
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioTracks || !req.libraryItem.isBook) {
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioTracks) {
|
||||
Logger.error(`[ToolsController] Invalid library item`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
@ -129,7 +129,7 @@ class ToolsController {
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (libraryItem.isMissing || !libraryItem.hasAudioTracks || !libraryItem.isBook) {
|
||||
if (libraryItem.isMissing || !libraryItem.hasAudioTracks) {
|
||||
Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
@ -40,6 +40,17 @@ class AudioMetadataMangaer {
|
||||
* @returns
|
||||
*/
|
||||
getMetadataObjectForApi(libraryItem) {
|
||||
if (libraryItem.isPodcast) {
|
||||
return {
|
||||
title: libraryItem.media.title,
|
||||
artist: libraryItem.media.author,
|
||||
album_artist: libraryItem.media.author,
|
||||
album: libraryItem.media.title,
|
||||
genre: libraryItem.media.genres?.join('; '),
|
||||
description: libraryItem.media.description,
|
||||
language: libraryItem.media.language
|
||||
}
|
||||
}
|
||||
return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length)
|
||||
}
|
||||
|
||||
@ -65,17 +76,33 @@ class AudioMetadataMangaer {
|
||||
const forceEmbedChapters = !!options.forceEmbedChapters
|
||||
const backupFiles = !!options.backup
|
||||
|
||||
const audioFiles = libraryItem.media.includedAudioFiles
|
||||
const audioFiles = libraryItem.isPodcast
|
||||
? libraryItem.media.podcastEpisodes
|
||||
.filter((ep) => !!ep.audioFile && !!ep.audioFile.metadata?.path)
|
||||
.map((ep) => ({
|
||||
episode: ep,
|
||||
index: 1,
|
||||
ino: ep.audioFile?.ino,
|
||||
filename: ep.audioFile?.metadata?.filename,
|
||||
path: ep.audioFile?.metadata?.path,
|
||||
duration: ep.duration,
|
||||
mimeType: ep.audioFile?.mimeType
|
||||
}))
|
||||
: libraryItem.media.includedAudioFiles
|
||||
|
||||
if (!audioFiles.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const task = new Task()
|
||||
|
||||
const itemCachePath = Path.join(this.itemsCacheDir, libraryItem.id)
|
||||
|
||||
// Only writing chapters for single file audiobooks
|
||||
const chapters = audioFiles.length == 1 || forceEmbedChapters ? libraryItem.media.chapters.map((c) => ({ ...c })) : null
|
||||
const chapters = libraryItem.isPodcast ? null : audioFiles.length == 1 || forceEmbedChapters ? libraryItem.media.chapters.map((c) => ({ ...c })) : null
|
||||
|
||||
let mimeType = audioFiles[0].mimeType
|
||||
if (audioFiles.some((a) => a.mimeType !== mimeType)) mimeType = null
|
||||
let mimeType = libraryItem.isPodcast ? audioFiles[0]?.mimeType || null : audioFiles[0].mimeType
|
||||
if (audioFiles.some((a) => (libraryItem.isPodcast ? a.mimeType : a.mimeType) !== mimeType)) mimeType = null
|
||||
|
||||
// Create task
|
||||
const libraryItemDir = libraryItem.isFile ? Path.dirname(libraryItem.path) : libraryItem.path
|
||||
@ -83,16 +110,51 @@ class AudioMetadataMangaer {
|
||||
libraryItemId: libraryItem.id,
|
||||
libraryItemDir,
|
||||
userId,
|
||||
audioFiles: audioFiles.map((af) => ({
|
||||
index: af.index,
|
||||
ino: af.ino,
|
||||
filename: af.metadata.filename,
|
||||
path: af.metadata.path,
|
||||
cachePath: Path.join(itemCachePath, af.metadata.filename),
|
||||
duration: af.duration
|
||||
})),
|
||||
audioFiles: libraryItem.isPodcast
|
||||
? audioFiles.map((af) => ({
|
||||
index: 1,
|
||||
ino: af.ino,
|
||||
filename: af.filename,
|
||||
path: af.path,
|
||||
cachePath: Path.join(itemCachePath, af.filename || 'episode'),
|
||||
duration: af.duration,
|
||||
episodeId: af.episode?.id,
|
||||
mimeType: af.mimeType
|
||||
}))
|
||||
: audioFiles.map((af) => ({
|
||||
index: af.index,
|
||||
ino: af.ino,
|
||||
filename: af.metadata.filename,
|
||||
path: af.metadata.path,
|
||||
cachePath: Path.join(itemCachePath, af.metadata.filename),
|
||||
duration: af.duration
|
||||
})),
|
||||
coverPath: libraryItem.media.coverPath,
|
||||
metadataObject: ffmpegHelpers.getFFMetadataObject(libraryItem, audioFiles.length),
|
||||
metadataObject: libraryItem.isPodcast ? null : ffmpegHelpers.getFFMetadataObject(libraryItem, audioFiles.length),
|
||||
podcast: libraryItem.isPodcast
|
||||
? {
|
||||
title: libraryItem.media.title,
|
||||
author: libraryItem.media.author,
|
||||
genres: Array.isArray(libraryItem.media.genres) ? [...libraryItem.media.genres] : [],
|
||||
language: libraryItem.media.language,
|
||||
itunesId: libraryItem.media.itunesId,
|
||||
podcastType: libraryItem.media.podcastType,
|
||||
releaseDate: libraryItem.media.releaseDate
|
||||
}
|
||||
: null,
|
||||
podcastEpisodes: libraryItem.isPodcast
|
||||
? libraryItem.media.podcastEpisodes.map((ep) => ({
|
||||
id: ep.id,
|
||||
title: ep.title,
|
||||
description: ep.description,
|
||||
subtitle: ep.subtitle,
|
||||
season: ep.season,
|
||||
episode: ep.episode,
|
||||
episodeType: ep.episodeType,
|
||||
pubDate: ep.pubDate,
|
||||
chapters: Array.isArray(ep.chapters) ? ep.chapters.map((c) => ({ ...c })) : []
|
||||
}))
|
||||
: null,
|
||||
itemCachePath,
|
||||
chapters,
|
||||
mimeType,
|
||||
@ -100,18 +162,24 @@ class AudioMetadataMangaer {
|
||||
forceEmbedChapters,
|
||||
backupFiles
|
||||
},
|
||||
duration: libraryItem.media.duration
|
||||
duration: libraryItem.isPodcast ? audioFiles.reduce((acc, af) => acc + (af.duration || 0), 0) || 0 : libraryItem.media.duration
|
||||
}
|
||||
|
||||
const taskTitleString = {
|
||||
text: 'Embedding Metadata',
|
||||
key: 'MessageTaskEmbeddingMetadata'
|
||||
}
|
||||
const taskDescriptionString = {
|
||||
text: `Embedding metadata in audiobook "${libraryItem.media.title}".`,
|
||||
key: 'MessageTaskEmbeddingMetadataDescription',
|
||||
subs: [libraryItem.media.title]
|
||||
}
|
||||
const taskDescriptionString = libraryItem.isPodcast
|
||||
? {
|
||||
text: `Embedding metadata in podcast "${libraryItem.media.title}" episodes.`,
|
||||
key: 'MessageTaskEmbeddingMetadataDescription',
|
||||
subs: [libraryItem.media.title]
|
||||
}
|
||||
: {
|
||||
text: `Embedding metadata in audiobook "${libraryItem.media.title}".`,
|
||||
key: 'MessageTaskEmbeddingMetadataDescription',
|
||||
subs: [libraryItem.media.title]
|
||||
}
|
||||
task.setData('embed-metadata', taskTitleString, taskDescriptionString, false, taskData)
|
||||
|
||||
if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) {
|
||||
@ -185,20 +253,24 @@ class AudioMetadataMangaer {
|
||||
}
|
||||
}
|
||||
|
||||
// Create ffmetadata file
|
||||
const ffmetadataPath = Path.join(task.data.itemCachePath, 'ffmetadata.txt')
|
||||
const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, ffmetadataPath)
|
||||
if (!success) {
|
||||
Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
|
||||
const taskFailedString = {
|
||||
text: 'Failed to write metadata file',
|
||||
key: 'MessageTaskFailedToWriteMetadataFile'
|
||||
let ffmetadataPath = Path.join(task.data.itemCachePath, 'ffmetadata.txt')
|
||||
// Pre-write single ffmetadata file for non-podcast items
|
||||
if (task.data.metadataObject) {
|
||||
const success = await ffmpegHelpers.writeFFMetadataFile(task.data.metadataObject, task.data.chapters, ffmetadataPath)
|
||||
if (!success) {
|
||||
Logger.error(`[AudioMetadataManager] Failed to write ffmetadata file for audiobook "${task.data.libraryItemId}"`)
|
||||
const taskFailedString = {
|
||||
text: 'Failed to write metadata file',
|
||||
key: 'MessageTaskFailedToWriteMetadataFile'
|
||||
}
|
||||
task.setFailed(taskFailedString)
|
||||
this.handleTaskFinished(task)
|
||||
return
|
||||
}
|
||||
task.setFailed(taskFailedString)
|
||||
this.handleTaskFinished(task)
|
||||
return
|
||||
}
|
||||
|
||||
const createdFFMetadataFiles = []
|
||||
|
||||
// Tag audio files
|
||||
let cummulativeProgress = 0
|
||||
for (const af of task.data.audioFiles) {
|
||||
@ -228,7 +300,23 @@ class AudioMetadataMangaer {
|
||||
}
|
||||
|
||||
try {
|
||||
await ffmpegHelpers.addCoverAndMetadataToFile(af.path, task.data.coverPath, ffmetadataPath, af.index, task.data.mimeType, (progress) => {
|
||||
// For podcasts, write per-episode ffmetadata file and chapters
|
||||
let perFileMetaPath = ffmetadataPath
|
||||
if (!task.data.metadataObject) {
|
||||
// Podcast flow: metadataObject is null; generate per-episode metadata
|
||||
const episode = (task.data.podcastEpisodes || []).find((ep) => ep.id === af.episodeId)
|
||||
const liStub = { media: task.data.podcast }
|
||||
const perEpisodeMeta = ffmpegHelpers.getPodcastEpisodeFFMetadataObject(liStub, episode)
|
||||
perFileMetaPath = Path.join(task.data.itemCachePath, `${af.filename || 'episode'}.ffmetadata.txt`)
|
||||
const episodeChapters = Array.isArray(episode?.chapters) && episode.chapters.length ? episode.chapters.map((c) => ({ ...c })) : null
|
||||
const wrote = await ffmpegHelpers.writeFFMetadataFile(perEpisodeMeta, episodeChapters, perFileMetaPath)
|
||||
if (!wrote) {
|
||||
throw new Error('Failed to write episode ffmetadata file')
|
||||
}
|
||||
createdFFMetadataFiles.push(perFileMetaPath)
|
||||
}
|
||||
|
||||
await ffmpegHelpers.addCoverAndMetadataToFile(af.path, task.data.coverPath, perFileMetaPath, af.index, af.mimeType || task.data.mimeType, (progress) => {
|
||||
SocketAuthority.adminEmitter('task_progress', { libraryItemId: task.data.libraryItemId, progress: cummulativeProgress + progress * audioFileRelativeDuration })
|
||||
SocketAuthority.adminEmitter('track_progress', { libraryItemId: task.data.libraryItemId, ino: af.ino, progress })
|
||||
})
|
||||
@ -259,7 +347,13 @@ class AudioMetadataMangaer {
|
||||
if (cacheDirCreated) {
|
||||
await fs.remove(task.data.itemCachePath)
|
||||
} else {
|
||||
await fs.remove(ffmetadataPath)
|
||||
if (createdFFMetadataFiles.length) {
|
||||
for (const metaPath of createdFFMetadataFiles) {
|
||||
await fs.remove(metaPath)
|
||||
}
|
||||
} else {
|
||||
await fs.remove(ffmetadataPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -426,6 +426,38 @@ function getFFMetadataObject(libraryItem, audioFilesLength) {
|
||||
|
||||
module.exports.getFFMetadataObject = getFFMetadataObject
|
||||
|
||||
/**
|
||||
* Build ffmetadata key-value pairs for a single podcast episode based on the library item and episode.
|
||||
*
|
||||
* @param {import('../models/LibraryItem')} libraryItem
|
||||
* @param {import('../models/PodcastEpisode')} podcastEpisode
|
||||
* @returns {Object}
|
||||
*/
|
||||
function getPodcastEpisodeFFMetadataObject(libraryItem, podcastEpisode) {
|
||||
const podcast = libraryItem.media
|
||||
const ffmetadata = {
|
||||
title: podcastEpisode.title || podcast.title,
|
||||
artist: podcast.author || podcast.title,
|
||||
album_artist: podcast.author || podcast.title,
|
||||
album: podcast.title,
|
||||
genre: Array.isArray(podcast.genres) && podcast.genres.length ? podcast.genres.join('; ') : undefined,
|
||||
date: podcast.releasedate,
|
||||
comment: podcastEpisode.description,
|
||||
description: podcastEpisode.description,
|
||||
language: podcast.language,
|
||||
'itunes-id': podcast.itunesId,
|
||||
track: podcastEpisode.episode || undefined,
|
||||
disc: podcastEpisode.season || undefined,
|
||||
}
|
||||
|
||||
Object.keys(ffmetadata).forEach((key) => {
|
||||
if (ffmetadata[key] === undefined || ffmetadata[key] === null || ffmetadata[key] === '') delete ffmetadata[key]
|
||||
})
|
||||
return ffmetadata
|
||||
}
|
||||
|
||||
module.exports.getPodcastEpisodeFFMetadataObject = getPodcastEpisodeFFMetadataObject
|
||||
|
||||
/**
|
||||
* Merges audio files into a single output file using FFmpeg.
|
||||
*
|
||||
|
Loading…
Reference in New Issue
Block a user