From 03542a540f56f20f3e85f5780d32196178983123 Mon Sep 17 00:00:00 2001 From: Arunesh Dwivedi Date: Thu, 4 Jun 2026 07:20:48 +0000 Subject: [PATCH 1/3] fix: include episode data in podcast metadata.json The metadata.json written for podcast libraries only included podcast-level metadata (title, author, description, etc.) but not episode-level data like titles, descriptions, pubDates, and chapters. This meant that when a podcast library was moved or restored from backup, all episode metadata was lost. This change adds an episodes array to the metadata.json output containing the key fields for each episode: title, subtitle, season, number, type, pubDate, publishedAt, description, and chapters. Fixes #5093 --- server/scanner/PodcastScanner.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index f3241d536..f0ae1eeaf 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -443,7 +443,18 @@ class PodcastScanner { asin: libraryItem.media.asin, language: libraryItem.media.language, explicit: !!libraryItem.media.explicit, - podcastType: libraryItem.media.podcastType + 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 || [] + })) } return fsExtra .writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)) From 49c06f16f508810032090c73fbd3ef70c1b0be2f Mon Sep 17 00:00:00 2001 From: Arunesh Dwivedi Date: Thu, 4 Jun 2026 07:29:01 +0000 Subject: [PATCH 2/3] 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() From 9477d9a808054cff2e240606b2b3374c2c3fdacb Mon Sep 17 00:00:00 2001 From: Arunesh Dwivedi Date: Thu, 4 Jun 2026 17:05:34 +0000 Subject: [PATCH 3/3] refactor: simplify episode metadata writer Remove verbose JSDoc, shorten variable names, match existing code style. Rename saveEpisodeMetadataFiles -> writeEpisodeMetadata. --- server/scanner/PodcastScanner.js | 62 +++++++++++++------------------- 1 file changed, 24 insertions(+), 38 deletions(-) diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index 7004c6633..0ddca869c 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -477,9 +477,8 @@ 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) + await this.writeEpisodeMetadata(libraryItem, libraryItem.media.podcastEpisodes, libraryScan) } return metadataLibraryFile @@ -490,48 +489,35 @@ class PodcastScanner { }) } - /** - * 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 + async writeEpisodeMetadata(libraryItem, episodes, libraryScan) { + for (const ep of episodes) { + if (!ep.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 || [] + const metaPath = ep.audioFile.metadata.path.replace(/\.[^.]+$/, '.json') + const data = { + 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 || [] } try { - await fsExtra.writeFile(episodeMetadataPath, JSON.stringify(episodeJson, null, 2)) - libraryScan.addLog(LogLevel.DEBUG, `Saved episode metadata to "${episodeMetadataPath}"`) + await fsExtra.writeFile(metaPath, JSON.stringify(data, null, 2)) + libraryScan.addLog(LogLevel.DEBUG, `Saved episode meta: ${metaPath}`) - // 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()) + const exists = libraryItem.libraryFiles.find((f) => f.metadata.path === filePathToPOSIX(metaPath)) + if (!exists) { + const lf = new LibraryFile() + await lf.setDataFromPath(metaPath, Path.basename(metaPath)) + libraryItem.libraryFiles.push(lf.toJSON()) } - } catch (error) { - libraryScan.addLog(LogLevel.ERROR, `Failed to save episode metadata at "${episodeMetadataPath}"`, error) + } catch (err) { + libraryScan.addLog(LogLevel.ERROR, `Failed writing ${metaPath}: ${err.message}`) } } }