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..d66874c56 100644 --- a/client/components/modals/item/tabs/Schedule.vue +++ b/client/components/modals/item/tabs/Schedule.vue @@ -31,9 +31,16 @@ + + -
+
{{ isUpdated ? $strings.ButtonSave : $strings.MessageNoUpdatesWereNecessary }} @@ -54,6 +61,7 @@ export default { data() { return { enableAutoDownloadEpisodes: false, + enableFetchEpisodeMetadata: false, cronExpression: null, newMaxEpisodesToKeep: 0, newMaxNewEpisodesToDownload: 0 @@ -94,6 +102,9 @@ export default { autoDownloadEpisodes() { return !!this.media.autoDownloadEpisodes }, + fetchEpisodeMetadata() { + return !!this.media.fetchEpisodeMetadata + }, autoDownloadSchedule() { return this.media.autoDownloadSchedule }, @@ -104,7 +115,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) || this.fetchEpisodeMetadata !== this.enableFetchEpisodeMetadata } }, methods: { @@ -152,6 +163,10 @@ export default { updatePayload.maxNewEpisodesToDownload = this.newMaxNewEpisodesToDownload } + if (this.enableFetchEpisodeMetadata !== this.fetchEpisodeMetadata) { + updatePayload.fetchEpisodeMetadata = this.enableFetchEpisodeMetadata + } + this.updateDetails(updatePayload) }, async updateDetails(updatePayload) { @@ -175,6 +190,7 @@ export default { }, init() { this.enableAutoDownloadEpisodes = this.autoDownloadEpisodes + this.enableFetchEpisodeMetadata = this.fetchEpisodeMetadata this.cronExpression = this.autoDownloadSchedule this.newMaxEpisodesToKeep = this.maxEpisodesToKeep this.newMaxNewEpisodesToDownload = this.maxNewEpisodesToDownload 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/strings/en-us.json b/client/strings/en-us.json index fb2bcb281..c02bc4455 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 from RSS feed", "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