Add podcast support to metadata embedding tools

This commit is contained in:
Vito0912 2025-08-16 15:58:05 +02:00
parent fd4932cdbb
commit 69b6c0c79a
No known key found for this signature in database
GPG Key ID: A0F767011D6093A2
6 changed files with 176 additions and 39 deletions

View File

@ -168,7 +168,7 @@ export default {
}
]
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
if (this.selectedMediaItemsArePlayable) {
options.push({
text: this.$strings.ButtonQuickEmbedMetadata,
action: 'quick-embed'

View File

@ -115,7 +115,6 @@ export default {
id: 'tools',
title: this.$strings.HeaderTools,
component: 'modals-item-tabs-tools',
mediaType: 'book',
admin: true
},
{

View File

@ -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 || []
},

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -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.
*