From 49c06f16f508810032090c73fbd3ef70c1b0be2f Mon Sep 17 00:00:00 2001 From: Arunesh Dwivedi Date: Thu, 4 Jun 2026 07:29:01 +0000 Subject: [PATCH] fix: write per-episode metadata files for podcast libraries The metadata.json for podcasts only contained podcast-level metadata (title, author, etc.) but no episode data. When a podcast library was moved or restored from backup, all episode metadata was lost. This writes per-episode .json files alongside each episode's audio file (e.g. episode.mp3 -> episode.json) containing title, subtitle, season, episode, episodeType, pubDate, publishedAt, description, and chapters. This matches the flat structure approach suggested in issue #5093 since podcast libraries are structured differently from books (flat vs directory-based). Fixes #5093 --- server/scanner/PodcastScanner.js | 64 ++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index f0ae1eeaf..7004c6633 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -443,18 +443,7 @@ class PodcastScanner { asin: libraryItem.media.asin, language: libraryItem.media.language, explicit: !!libraryItem.media.explicit, - podcastType: libraryItem.media.podcastType, - episodes: (libraryItem.media.podcastEpisodes || []).map((ep) => ({ - title: ep.title, - subtitle: ep.subtitle, - season: ep.season, - episode: ep.episode, - episodeType: ep.episodeType, - pubDate: ep.pubDate, - publishedAt: ep.publishedAt, - description: ep.description, - chapters: ep.chapters || [] - })) + podcastType: libraryItem.media.podcastType } return fsExtra .writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)) @@ -488,6 +477,11 @@ class PodcastScanner { libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) + // Write per-episode metadata files alongside each episode's audio file + if (storeMetadataWithItem && libraryItem.media.podcastEpisodes?.length) { + await this.saveEpisodeMetadataFiles(libraryItem, libraryItem.media.podcastEpisodes, libraryScan) + } + return metadataLibraryFile }) .catch((error) => { @@ -495,5 +489,51 @@ class PodcastScanner { return null }) } + + /** + * Write per-episode metadata files alongside each episode's audio file. + * For an episode at /path/to/episode.mp3, writes /path/to/episode.json + * containing title, description, pubDate, chapters, etc. + * + * @param {import('../models/LibraryItem')} libraryItem + * @param {import('../models/PodcastEpisode')[]} podcastEpisodes + * @param {import('./LibraryScan')} libraryScan + * @returns {Promise} + */ + async saveEpisodeMetadataFiles(libraryItem, podcastEpisodes, libraryScan) { + for (const episode of podcastEpisodes) { + if (!episode.audioFile?.metadata?.path) continue + + const audioFilePath = episode.audioFile.metadata.path + const episodeMetadataPath = audioFilePath.replace(/\.[^.]+$/, '.json') + + const episodeJson = { + title: episode.title, + subtitle: episode.subtitle, + season: episode.season, + episode: episode.episode, + episodeType: episode.episodeType, + pubDate: episode.pubDate, + publishedAt: episode.publishedAt, + description: episode.description, + chapters: episode.chapters || [] + } + + try { + await fsExtra.writeFile(episodeMetadataPath, JSON.stringify(episodeJson, null, 2)) + libraryScan.addLog(LogLevel.DEBUG, `Saved episode metadata to "${episodeMetadataPath}"`) + + // Add to libraryFiles if not already tracked + const metadataLibraryFile = libraryItem.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(episodeMetadataPath)) + if (!metadataLibraryFile) { + const newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(episodeMetadataPath, Path.basename(episodeMetadataPath)) + libraryItem.libraryFiles.push(newLibraryFile.toJSON()) + } + } catch (error) { + libraryScan.addLog(LogLevel.ERROR, `Failed to save episode metadata at "${episodeMetadataPath}"`, error) + } + } + } } module.exports = new PodcastScanner()