From 69b6c0c79aae1d3115525f8de2fab2c949961a24 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 16 Aug 2025 15:58:05 +0200 Subject: [PATCH 1/2] Add podcast support to metadata embedding tools --- client/components/app/Appbar.vue | 2 +- client/components/modals/item/EditModal.vue | 1 - client/components/modals/item/tabs/Tools.vue | 18 ++- server/controllers/ToolsController.js | 4 +- server/managers/AudioMetadataManager.js | 158 +++++++++++++++---- server/utils/ffmpegHelpers.js | 32 ++++ 6 files changed, 176 insertions(+), 39 deletions(-) diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index f74134041..b41a12de9 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -168,7 +168,7 @@ export default { } ] - if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) { + if (this.selectedMediaItemsArePlayable) { options.push({ text: this.$strings.ButtonQuickEmbedMetadata, action: 'quick-embed' diff --git a/client/components/modals/item/EditModal.vue b/client/components/modals/item/EditModal.vue index 232c32282..45e25207e 100644 --- a/client/components/modals/item/EditModal.vue +++ b/client/components/modals/item/EditModal.vue @@ -115,7 +115,6 @@ export default { id: 'tools', title: this.$strings.HeaderTools, component: 'modals-item-tabs-tools', - mediaType: 'book', admin: true }, { diff --git a/client/components/modals/item/tabs/Tools.vue b/client/components/modals/item/tabs/Tools.vue index d96550887..750806e79 100644 --- a/client/components/modals/item/tabs/Tools.vue +++ b/client/components/modals/item/tabs/Tools.vue @@ -20,7 +20,7 @@ -
+

{{ $strings.LabelToolsEmbedMetadata }}

@@ -28,7 +28,7 @@
- {{ $strings.ButtonOpenManager }} launch @@ -48,7 +48,7 @@
-

{{ $strings.MessageNoAudioTracks }}

+

{{ $strings.MessageNoAudioTracks }}

@@ -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 || [] }, diff --git a/server/controllers/ToolsController.js b/server/controllers/ToolsController.js index 9f2014ec0..6cee14fed 100644 --- a/server/controllers/ToolsController.js +++ b/server/controllers/ToolsController.js @@ -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) } diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js index 7471a1ca0..3b8beb8e3 100644 --- a/server/managers/AudioMetadataManager.js +++ b/server/managers/AudioMetadataManager.js @@ -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) + } } } diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index 357a20845..48515a0be 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -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. * From 44f13ea4f60a4daf2034889855ebeaf2e9ddbe61 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sun, 17 Aug 2025 08:04:52 +0200 Subject: [PATCH 2/2] fix timestamps --- server/utils/ffmpegHelpers.js | 95 ++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 41 deletions(-) diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index 48515a0be..a54aff1f2 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -152,29 +152,7 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { const podcastEpisode = podcastEpisodeDownload.rssPodcastEpisode const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0) - const taggings = { - album: podcast.title, - 'album-sort': podcast.title, - artist: podcast.author, - 'artist-sort': podcast.author, - comment: podcastEpisode.description, - subtitle: podcastEpisode.subtitle, - disc: podcastEpisode.season, - genre: podcast.genres.length ? podcast.genres.join(';') : null, - language: podcast.language, - MVNM: podcast.title, - MVIN: podcastEpisode.episode, - track: podcastEpisode.episode, - 'series-part': podcastEpisode.episode, - title: podcastEpisode.title, - 'title-sort': podcastEpisode.title, - year: podcastEpisodeDownload.pubYear, - date: podcastEpisode.pubDate, - releasedate: podcastEpisode.pubDate, - 'itunes-id': podcast.itunesId, - 'podcast-type': podcast.podcastType, - 'episode-type': podcastEpisode.episodeType - } + const taggings = getPodcastEpisodeFFMetadataObject(podcast, podcastEpisode, podcastEpisodeDownload) for (const tag in taggings) { if (taggings[tag]) { @@ -433,27 +411,62 @@ module.exports.getFFMetadataObject = getFFMetadataObject * @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, +function getPodcastEpisodeFFMetadataObject(podcast, podcastEpisode, podcastEpisodeDownload = {}) { + function formatToISO8601(date) { + if (!date) return null + + let d + if (date instanceof Date) { + d = date + } else { + d = new Date(date) + } + + if (isNaN(d.getTime())) return null + return d.toISOString() } - Object.keys(ffmetadata).forEach((key) => { - if (ffmetadata[key] === undefined || ffmetadata[key] === null || ffmetadata[key] === '') delete ffmetadata[key] + const pubDateISO = formatToISO8601(podcastEpisode.pubDate) + let pubYearISO = null + if (podcastEpisodeDownload?.pubYear) { + pubYearISO = podcastEpisodeDownload.pubYear + } else if (podcastEpisode.pubDate) { + const pubDateObj = new Date(podcastEpisode.pubDate) + if (pubDateObj && !isNaN(pubDateObj.getTime())) { + pubYearISO = pubDateObj.getUTCFullYear().toString() + } + } + + const taggings = { + album: podcast.title, + 'album-sort': podcast.title, + artist: podcast.author, + 'artist-sort': podcast.author, + comment: podcastEpisode.description, + subtitle: podcastEpisode.subtitle, + disc: podcastEpisode.season, + genre: Array.isArray(podcast.genres) && podcast.genres.length ? podcast.genres.join(';') : null, + language: podcast.language, + MVNM: podcast.title, + MVIN: podcastEpisode.episode, + track: podcastEpisode.episode, + 'series-part': podcastEpisode.episode, + title: podcastEpisode.title, + 'title-sort': podcastEpisode.title, + year: pubYearISO, + date: pubDateISO, + releasedate: pubDateISO, + 'itunes-id': podcast.itunesId, + 'podcast-type': podcast.podcastType, + 'episode-type': podcastEpisode.episodeType + } + + Object.keys(taggings).forEach((key) => { + if (taggings[key] === undefined || taggings[key] === null || taggings[key] === '') { + delete taggings[key] + } }) - return ffmetadata + return taggings } module.exports.getPodcastEpisodeFFMetadataObject = getPodcastEpisodeFFMetadataObject