diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index fdb57fbc5..dea55c1f9 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -6,7 +6,7 @@ on:
workflow_dispatch:
inputs:
tags:
- description: 'Docker Tag'
+ description: 'Docker Tag (e.g. latest)'
required: true
default: 'latest'
push:
@@ -22,7 +22,7 @@ on:
jobs:
build:
- if: ${{ !contains(github.event.head_commit.message, 'skip ci') && github.repository == 'advplyr/audiobookshelf' }}
+ if: ${{ !contains(github.event.head_commit.message, 'skip ci') }}
runs-on: ubuntu-24.04
steps:
@@ -33,8 +33,9 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
- images: advplyr/audiobookshelf,ghcr.io/${{ github.repository_owner }}/audiobookshelf
+ images: entree3000/audiobookshelf
tags: |
+ type=edge,branch=main
type=edge,branch=master
type=semver,pattern={{version}}
@@ -58,17 +59,10 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- - name: Login to ghcr
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.repository_owner }}
- password: ${{ secrets.GHCR_PASSWORD }}
-
- name: Build image
uses: docker/build-push-action@v6
with:
- tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
+ tags: ${{ github.event.inputs.tags && format('entree3000/audiobookshelf:{0}', github.event.inputs.tags) || steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
context: .
platforms: linux/amd64,linux/arm64
diff --git a/client/components/modals/item/tabs/Schedule.vue b/client/components/modals/item/tabs/Schedule.vue
index 0faa29630..4b7d5d966 100644
--- a/client/components/modals/item/tabs/Schedule.vue
+++ b/client/components/modals/item/tabs/Schedule.vue
@@ -31,6 +31,7 @@
+
@@ -104,8 +105,7 @@ export default {
return this.media.maxNewEpisodesToDownload
},
isUpdated() {
- return this.autoDownloadSchedule !== this.cronExpression || this.autoDownloadEpisodes !== this.enableAutoDownloadEpisodes || this.maxEpisodesToKeep !== Number(this.newMaxEpisodesToKeep) || this.maxNewEpisodesToDownload !== Number(this.newMaxNewEpisodesToDownload)
- }
+ return this.autoDownloadSchedule !== this.cronExpression || this.autoDownloadEpisodes !== this.enableAutoDownloadEpisodes || this.maxEpisodesToKeep !== Number(this.newMaxEpisodesToKeep) || this.maxNewEpisodesToDownload !== Number(this.newMaxNewEpisodesToDownload) }
},
methods: {
updatedMaxEpisodesToKeep() {
diff --git a/client/components/modals/podcast/NewModal.vue b/client/components/modals/podcast/NewModal.vue
index 33e6dbc4b..02b783985 100644
--- a/client/components/modals/podcast/NewModal.vue
+++ b/client/components/modals/podcast/NewModal.vue
@@ -56,6 +56,9 @@
+
+
+
{{ $strings.ButtonSubmit }}
@@ -94,6 +97,7 @@ export default {
itunesId: '',
itunesArtistId: '',
autoDownloadEpisodes: false,
+ fetchEpisodeMetadata: false,
language: '',
explicit: false,
type: ''
@@ -196,7 +200,8 @@ export default {
explicit: this.podcast.explicit,
type: this.podcast.type
},
- autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
+ autoDownloadEpisodes: this.podcast.autoDownloadEpisodes,
+ fetchEpisodeMetadata: this.podcast.fetchEpisodeMetadata
}
}
console.log('Podcast payload', podcastPayload)
diff --git a/client/components/widgets/PodcastDetailsEdit.vue b/client/components/widgets/PodcastDetailsEdit.vue
index 0be67df32..f92fad1f1 100644
--- a/client/components/widgets/PodcastDetailsEdit.vue
+++ b/client/components/widgets/PodcastDetailsEdit.vue
@@ -43,6 +43,11 @@
+
@@ -73,7 +78,8 @@ export default {
language: null,
type: null
},
- newTags: []
+ newTags: [],
+ enableFetchEpisodeMetadata: false
}
},
watch: {
@@ -223,6 +229,10 @@ export default {
updatePayload.tags = [...this.newTags]
}
+ if (this.enableFetchEpisodeMetadata !== !!this.media.fetchEpisodeMetadata) {
+ updatePayload.fetchEpisodeMetadata = this.enableFetchEpisodeMetadata
+ }
+
return {
updatePayload,
hasChanges: !!Object.keys(updatePayload).length
@@ -244,6 +254,7 @@ export default {
this.details.type = this.mediaMetadata.type || 'episodic'
this.newTags = [...(this.media.tags || [])]
+ this.enableFetchEpisodeMetadata = !!this.media.fetchEpisodeMetadata
},
submitForm() {
this.$emit('submit')
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index fb2bcb281..aa0d91926 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -191,6 +191,7 @@
"HeaderRemoveEpisodes": "Remove {0} Episodes",
"HeaderSavedMediaProgress": "Saved Media Progress",
"HeaderSchedule": "Schedule",
+ "HeaderEpisodeMetadata": "Episode Metadata",
"HeaderScheduleEpisodeDownloads": "Schedule Automatic Episode Downloads",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
"HeaderSession": "Session",
@@ -256,6 +257,7 @@
"LabelAuthorLastFirst": "Author (Last, First)",
"LabelAuthors": "Authors",
"LabelAutoDownloadEpisodes": "Auto Download Episodes",
+ "LabelFetchEpisodeMetadata": "Store Episode Metadata",
"LabelAutoFetchMetadata": "Auto Fetch Metadata",
"LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.",
"LabelAutoLaunch": "Auto Launch",
diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js
index 07b4ee67f..7fcf57911 100644
--- a/server/controllers/LibraryItemController.js
+++ b/server/controllers/LibraryItemController.js
@@ -215,6 +215,7 @@ class LibraryItemController {
// Podcast specific
let isPodcastAutoDownloadUpdated = false
+ let isFetchEpisodeMetadataEnabled = false
if (req.libraryItem.isPodcast) {
if (mediaPayload.autoDownloadEpisodes !== undefined && req.libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
isPodcastAutoDownloadUpdated = true
@@ -226,6 +227,11 @@ class LibraryItemController {
Logger.error(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${req.libraryItem.media.title}"`)
return res.status(400).send('Invalid auto download schedule cron expression')
}
+
+ // Check if fetchEpisodeMetadata is being toggled on
+ if (mediaPayload.fetchEpisodeMetadata && !req.libraryItem.media.fetchEpisodeMetadata) {
+ isFetchEpisodeMetadataEnabled = true
+ }
}
let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
@@ -276,6 +282,14 @@ class LibraryItemController {
this.cronManager.checkUpdatePodcastCron(req.libraryItem)
}
+ // Fetch and populate episode metadata from RSS when toggle is turned on
+ if (isFetchEpisodeMetadataEnabled) {
+ const episodesUpdated = await this.podcastManager.populateEpisodeMetadataFromFeed(req.libraryItem)
+ if (episodesUpdated) {
+ await req.libraryItem.saveMetadataFile()
+ }
+ }
+
Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`)
SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem)
}
diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js
index bdf6fc764..c9d8026c1 100644
--- a/server/managers/PodcastManager.js
+++ b/server/managers/PodcastManager.js
@@ -250,6 +250,11 @@ class PodcastManager {
await libraryItem.media.save()
}
+ // Save episode metadata to metadata.json if fetchEpisodeMetadata is enabled
+ if (libraryItem.media.fetchEpisodeMetadata) {
+ await libraryItem.saveMetadataFile()
+ }
+
SocketAuthority.libraryItemEmitter('item_updated', libraryItem)
const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id)
podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded()
@@ -739,5 +744,90 @@ class PodcastManager {
TaskManager.taskFinished(task)
Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`)
}
+
+ /**
+ * Fetch episode metadata from RSS feed and backfill missing fields on existing episodes
+ *
+ * @param {import('../models/LibraryItem')} libraryItem - must have media.podcastEpisodes loaded
+ * @returns {Promise} - true if any episodes were updated
+ */
+ async populateEpisodeMetadataFromFeed(libraryItem) {
+ const podcast = libraryItem.media
+ if (!podcast.feedURL) {
+ Logger.warn(`[PodcastManager] Cannot fetch episode metadata - no feed URL for "${podcast.title}"`)
+ return false
+ }
+
+ const feed = await getPodcastFeed(podcast.feedURL).catch((error) => {
+ Logger.error(`[PodcastManager] Failed to fetch feed for episode metadata population`, error)
+ return null
+ })
+ if (!feed?.episodes?.length) {
+ Logger.warn(`[PodcastManager] No episodes found in feed for "${podcast.title}"`)
+ return false
+ }
+
+ const episodes = podcast.podcastEpisodes || []
+ let hasUpdates = false
+
+ for (const episode of episodes) {
+ // Match by GUID first, then by title
+ const guid = episode.extraData?.guid
+ let matched = null
+ if (guid) {
+ matched = feed.episodes.find((fe) => fe.guid === guid)
+ }
+ if (!matched) {
+ const episodeTitle = (episode.title || '').trim().toLowerCase()
+ if (episodeTitle) {
+ matched = feed.episodes.find((fe) => (fe.title || '').trim().toLowerCase() === episodeTitle)
+ }
+ }
+
+ if (!matched) continue
+
+ let episodeUpdated = false
+ if (!episode.description && matched.description) {
+ episode.description = matched.description
+ episodeUpdated = true
+ }
+ if (!episode.season && matched.season) {
+ episode.season = matched.season
+ episodeUpdated = true
+ }
+ if (!episode.episode && matched.episode) {
+ episode.episode = matched.episode
+ episodeUpdated = true
+ }
+ if (!episode.episodeType && matched.episodeType) {
+ episode.episodeType = matched.episodeType
+ episodeUpdated = true
+ }
+ if (!episode.pubDate && matched.pubDate) {
+ episode.pubDate = matched.pubDate
+ episodeUpdated = true
+ }
+ if (!episode.subtitle && matched.subtitle) {
+ episode.subtitle = matched.subtitle
+ episodeUpdated = true
+ }
+ if (matched.guid && !episode.extraData?.guid) {
+ episode.extraData = { ...(episode.extraData || {}), guid: matched.guid }
+ episode.changed('extraData', true)
+ episodeUpdated = true
+ }
+
+ if (episodeUpdated) {
+ await episode.save()
+ hasUpdates = true
+ }
+ }
+
+ if (hasUpdates) {
+ Logger.info(`[PodcastManager] Populated episode metadata from feed for "${podcast.title}"`)
+ }
+
+ return hasUpdates
+ }
}
module.exports = PodcastManager
diff --git a/server/migrations/v2.36.0-podcast-add-fetch-episode-metadata.js b/server/migrations/v2.36.0-podcast-add-fetch-episode-metadata.js
new file mode 100644
index 000000000..4bd69df4d
--- /dev/null
+++ b/server/migrations/v2.36.0-podcast-add-fetch-episode-metadata.js
@@ -0,0 +1,68 @@
+/**
+ * @typedef MigrationContext
+ * @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
+ * @property {import('../Logger')} logger - a Logger object.
+ *
+ * @typedef MigrationOptions
+ * @property {MigrationContext} context - an object containing the migration context.
+ */
+
+const migrationVersion = '2.36.0'
+const migrationName = `${migrationVersion}-podcast-add-fetch-episode-metadata`
+const loggerPrefix = `[${migrationVersion} migration]`
+
+/**
+ * This migration script adds the fetchEpisodeMetadata column to the podcasts table.
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+async function up({ context: { queryInterface, logger } }) {
+ logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
+
+ if (await queryInterface.tableExists('podcasts')) {
+ const tableDescription = await queryInterface.describeTable('podcasts')
+ if (!tableDescription.fetchEpisodeMetadata) {
+ logger.info(`${loggerPrefix} Adding fetchEpisodeMetadata column to podcasts table`)
+ await queryInterface.addColumn('podcasts', 'fetchEpisodeMetadata', {
+ type: queryInterface.sequelize.Sequelize.DataTypes.BOOLEAN,
+ defaultValue: false,
+ allowNull: false
+ })
+ logger.info(`${loggerPrefix} Added fetchEpisodeMetadata column to podcasts table`)
+ } else {
+ logger.info(`${loggerPrefix} fetchEpisodeMetadata column already exists in podcasts table`)
+ }
+ } else {
+ logger.info(`${loggerPrefix} podcasts table does not exist`)
+ }
+
+ logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
+}
+
+/**
+ * This migration script removes the fetchEpisodeMetadata column from the podcasts table.
+ *
+ * @param {MigrationOptions} options - an object containing the migration context.
+ * @returns {Promise} - A promise that resolves when the migration is complete.
+ */
+async function down({ context: { queryInterface, logger } }) {
+ logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
+
+ if (await queryInterface.tableExists('podcasts')) {
+ const tableDescription = await queryInterface.describeTable('podcasts')
+ if (tableDescription.fetchEpisodeMetadata) {
+ logger.info(`${loggerPrefix} Removing fetchEpisodeMetadata column from podcasts table`)
+ await queryInterface.removeColumn('podcasts', 'fetchEpisodeMetadata')
+ logger.info(`${loggerPrefix} Removed fetchEpisodeMetadata column from podcasts table`)
+ } else {
+ logger.info(`${loggerPrefix} fetchEpisodeMetadata column does not exist in podcasts table`)
+ }
+ } else {
+ logger.info(`${loggerPrefix} podcasts table does not exist`)
+ }
+
+ logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
+}
+
+module.exports = { up, down }
diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js
index b33fa585c..2adeaf0b8 100644
--- a/server/models/LibraryItem.js
+++ b/server/models/LibraryItem.js
@@ -672,6 +672,20 @@ class LibraryItem extends Model {
explicit: !!mediaExpanded.explicit,
podcastType: mediaExpanded.podcastType
}
+
+ if (mediaExpanded.fetchEpisodeMetadata && mediaExpanded.podcastEpisodes?.length) {
+ jsonObject.episodes = mediaExpanded.podcastEpisodes.map((ep) => ({
+ title: ep.title,
+ description: ep.description || null,
+ season: ep.season || null,
+ episode: ep.episode || null,
+ episodeType: ep.episodeType || null,
+ pubDate: ep.pubDate || null,
+ guid: ep.extraData?.guid || null,
+ subtitle: ep.subtitle || null,
+ duration: ep.audioFile?.duration ? String(Math.round(ep.audioFile.duration)) : null
+ }))
+ }
}
return fsExtra
diff --git a/server/models/Podcast.js b/server/models/Podcast.js
index 6dbe1cd19..3f5fcbf15 100644
--- a/server/models/Podcast.js
+++ b/server/models/Podcast.js
@@ -44,6 +44,8 @@ class Podcast extends Model {
/** @type {boolean} */
this.explicit
/** @type {boolean} */
+ this.fetchEpisodeMetadata
+ /** @type {boolean} */
this.autoDownloadEpisodes
/** @type {string} */
this.autoDownloadSchedule
@@ -108,6 +110,7 @@ class Podcast extends Model {
language: typeof payload.metadata.language === 'string' ? payload.metadata.language : null,
podcastType: typeof payload.metadata.type === 'string' ? payload.metadata.type : null,
explicit: !!payload.metadata.explicit,
+ fetchEpisodeMetadata: !!payload.fetchEpisodeMetadata,
autoDownloadEpisodes: !!payload.autoDownloadEpisodes,
autoDownloadSchedule: autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule,
lastEpisodeCheck: new Date(),
@@ -146,6 +149,7 @@ class Podcast extends Model {
podcastType: DataTypes.STRING,
explicit: DataTypes.BOOLEAN,
+ fetchEpisodeMetadata: DataTypes.BOOLEAN,
autoDownloadEpisodes: DataTypes.BOOLEAN,
autoDownloadSchedule: DataTypes.STRING,
lastEpisodeCheck: DataTypes.DATE,
@@ -269,6 +273,10 @@ class Podcast extends Model {
hasUpdates = true
}
+ if (payload.fetchEpisodeMetadata !== undefined && payload.fetchEpisodeMetadata !== this.fetchEpisodeMetadata) {
+ this.fetchEpisodeMetadata = !!payload.fetchEpisodeMetadata
+ hasUpdates = true
+ }
if (payload.autoDownloadEpisodes !== undefined && payload.autoDownloadEpisodes !== this.autoDownloadEpisodes) {
this.autoDownloadEpisodes = !!payload.autoDownloadEpisodes
hasUpdates = true
@@ -443,6 +451,7 @@ class Podcast extends Model {
coverPath: this.coverPath,
tags: [...(this.tags || [])],
episodes: this.podcastEpisodes.map((episode) => episode.toOldJSON(libraryItemId)),
+ fetchEpisodeMetadata: this.fetchEpisodeMetadata,
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
@@ -459,6 +468,7 @@ class Podcast extends Model {
coverPath: this.coverPath,
tags: [...(this.tags || [])],
numEpisodes: this.podcastEpisodes?.length || 0,
+ fetchEpisodeMetadata: this.fetchEpisodeMetadata,
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
@@ -483,6 +493,7 @@ class Podcast extends Model {
coverPath: this.coverPath,
tags: [...(this.tags || [])],
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)),
+ fetchEpisodeMetadata: this.fetchEpisodeMetadata,
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js
index f3241d536..a56a42600 100644
--- a/server/scanner/PodcastScanner.js
+++ b/server/scanner/PodcastScanner.js
@@ -12,6 +12,71 @@ const fsExtra = require('../libs/fsExtra')
const PodcastEpisode = require('../models/PodcastEpisode')
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
const htmlSanitizer = require('../utils/htmlSanitizer')
+const Logger = require('../Logger')
+
+/**
+ * Match a podcast episode to an entry in the metadata episodes array
+ * @param {Object} episode - PodcastEpisode model or plain object
+ * @param {Object[]} metadataEpisodes - episodes array from metadata.json
+ * @returns {Object|null}
+ */
+function matchEpisodeMetadata(episode, metadataEpisodes) {
+ // Prefer GUID match
+ const guid = episode.extraData?.guid
+ if (guid) {
+ const match = metadataEpisodes.find((me) => me.guid === guid)
+ if (match) return match
+ }
+ // Fall back to title match (case-insensitive, trimmed)
+ const episodeTitle = (episode.title || '').trim().toLowerCase()
+ if (episodeTitle) {
+ const match = metadataEpisodes.find((me) => (me.title || '').trim().toLowerCase() === episodeTitle)
+ if (match) return match
+ }
+ return null
+}
+
+/**
+ * Backfill null episode fields from matched metadata
+ * @param {Object} episode - PodcastEpisode model or plain object
+ * @param {Object} matched - matched metadata entry
+ * @returns {boolean} whether any fields were updated
+ */
+function applyEpisodeMetadata(episode, matched) {
+ let updated = false
+ if (!episode.description && matched.description) {
+ episode.description = matched.description
+ updated = true
+ }
+ if (!episode.season && matched.season) {
+ episode.season = matched.season
+ updated = true
+ }
+ if (!episode.episode && matched.episode) {
+ episode.episode = matched.episode
+ updated = true
+ }
+ if (!episode.episodeType && matched.episodeType) {
+ episode.episodeType = matched.episodeType
+ updated = true
+ }
+ if (!episode.pubDate && matched.pubDate) {
+ episode.pubDate = matched.pubDate
+ updated = true
+ }
+ if (!episode.subtitle && matched.subtitle) {
+ episode.subtitle = matched.subtitle
+ updated = true
+ }
+ if (matched.guid && !episode.extraData?.guid) {
+ episode.extraData = { ...(episode.extraData || {}), guid: matched.guid }
+ if (typeof episode.changed === 'function') {
+ episode.changed('extraData', true)
+ }
+ updated = true
+ }
+ return updated
+}
/**
* Metadata for podcasts pulled from files
@@ -232,6 +297,17 @@ class PodcastScanner {
}
}
+ // Backfill episode metadata from metadata.json if fetchEpisodeMetadata is enabled
+ if (media.fetchEpisodeMetadata && podcastMetadata.episodes?.length) {
+ for (const episode of existingPodcastEpisodes) {
+ const matched = matchEpisodeMetadata(episode, podcastMetadata.episodes)
+ if (matched && applyEpisodeMetadata(episode, matched)) {
+ libraryScan.addLog(LogLevel.INFO, `Backfilled metadata for episode "${episode.title}" from metadata.json`)
+ await episode.save()
+ }
+ }
+ }
+
existingLibraryItem.media = media
let libraryItemUpdated = false
@@ -312,6 +388,16 @@ class PodcastScanner {
podcastMetadata.podcastType = 'episodic'
}
+ // Backfill episode metadata from metadata.json if episodes data exists
+ if (podcastMetadata.episodes?.length) {
+ for (const newEpisode of newPodcastEpisodes) {
+ const matched = matchEpisodeMetadata(newEpisode, podcastMetadata.episodes)
+ if (matched && applyEpisodeMetadata(newEpisode, matched)) {
+ libraryScan.addLog(LogLevel.INFO, `Backfilled metadata for episode "${newEpisode.title}" from metadata.json`)
+ }
+ }
+ }
+
const podcastObject = {
...podcastMetadata,
autoDownloadEpisodes: false,
@@ -445,6 +531,21 @@ class PodcastScanner {
explicit: !!libraryItem.media.explicit,
podcastType: libraryItem.media.podcastType
}
+
+ if (libraryItem.media.fetchEpisodeMetadata && libraryItem.media.podcastEpisodes?.length) {
+ jsonObject.episodes = libraryItem.media.podcastEpisodes.map((ep) => ({
+ title: ep.title,
+ description: ep.description || null,
+ season: ep.season || null,
+ episode: ep.episode || null,
+ episodeType: ep.episodeType || null,
+ pubDate: ep.pubDate || null,
+ guid: ep.extraData?.guid || null,
+ subtitle: ep.subtitle || null,
+ duration: ep.audioFile?.duration ? String(Math.round(ep.audioFile.duration)) : null
+ }))
+ }
+
return fsExtra
.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2))
.then(async () => {
diff --git a/server/utils/generators/abmetadataGenerator.js b/server/utils/generators/abmetadataGenerator.js
index d0d278752..7bb30e265 100644
--- a/server/utils/generators/abmetadataGenerator.js
+++ b/server/utils/generators/abmetadataGenerator.js
@@ -84,6 +84,16 @@ function parseJsonMetadataText(text, mediaType) {
validated.series = validated.series.map((series) => parseSeriesString.parse(series)).filter(Boolean)
}
+ if (mediaType === 'podcast' && 'episodes' in abmetadataData) {
+ if (abmetadataData.episodes === null) {
+ validated.episodes = []
+ } else if (Array.isArray(abmetadataData.episodes)) {
+ validated.episodes = cleanEpisodesArray(abmetadataData.episodes)
+ } else {
+ Logger.warn(`[abmetadataGenerator] Invalid metadata key "episodes" expected array, got ${typeof abmetadataData.episodes}`)
+ }
+ }
+
if (mediaType === 'book' && 'chapters' in abmetadataData) {
if (abmetadataData.chapters === null) {
validated.chapters = []
@@ -148,6 +158,29 @@ function validateMetadataValue(key, value, expectedType) {
return undefined
}
+/**
+ * @param {Object[]} episodesArray
+ * @returns {Object[]}
+ */
+function cleanEpisodesArray(episodesArray) {
+ const episodes = []
+ for (const ep of episodesArray) {
+ if (!ep.title || typeof ep.title !== 'string') continue
+ episodes.push({
+ title: ep.title,
+ description: typeof ep.description === 'string' ? ep.description : null,
+ season: typeof ep.season === 'string' ? ep.season : null,
+ episode: typeof ep.episode === 'string' ? ep.episode : null,
+ episodeType: typeof ep.episodeType === 'string' ? ep.episodeType : null,
+ pubDate: typeof ep.pubDate === 'string' ? ep.pubDate : null,
+ guid: typeof ep.guid === 'string' ? ep.guid : null,
+ subtitle: typeof ep.subtitle === 'string' ? ep.subtitle : null,
+ duration: typeof ep.duration === 'string' ? ep.duration : null
+ })
+ }
+ return episodes
+}
+
/**
* @param {Object[]} chaptersArray
* @param {string} mediaTitle