From 66fee7d39d75d249caa96835abfa32fe4169adfa Mon Sep 17 00:00:00 2001 From: Shaun Guimond Date: Mon, 1 Dec 2025 05:34:55 +0000 Subject: [PATCH] Added support for authenticated podcast feeds --- client/components/modals/podcast/NewModal.vue | 12 +++- .../components/widgets/PodcastDetailsEdit.vue | 5 +- client/strings/en-us.json | 1 + .../v2.30.1-add-is-authenticated-feed.js | 68 +++++++++++++++++++ server/models/Podcast.js | 10 +++ server/utils/ffmpegHelpers.js | 22 ++++-- 6 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 server/migrations/v2.30.1-add-is-authenticated-feed.js diff --git a/client/components/modals/podcast/NewModal.vue b/client/components/modals/podcast/NewModal.vue index 33e6dbc4b..a04301d9c 100644 --- a/client/components/modals/podcast/NewModal.vue +++ b/client/components/modals/podcast/NewModal.vue @@ -29,15 +29,18 @@
-
+
-
+
-
+
+
+ +
@@ -96,6 +99,7 @@ export default { autoDownloadEpisodes: false, language: '', explicit: false, + isAuthenticatedFeed: false, type: '' } } @@ -194,6 +198,7 @@ export default { itunesArtistId: this.podcast.itunesArtistId, language: this.podcast.language, explicit: this.podcast.explicit, + isAuthenticatedFeed: this.podcast.isAuthenticatedFeed, type: this.podcast.type }, autoDownloadEpisodes: this.podcast.autoDownloadEpisodes @@ -234,6 +239,7 @@ export default { this.podcast.type = this._podcastData.type || this.feedMetadata.type || 'episodic' this.podcast.explicit = this._podcastData.explicit || this.feedMetadata.explicit === 'yes' || this.feedMetadata.explicit == 'true' + this.podcast.isAuthenticatedFeed = this._podcastData.isAuthenticatedFeed || false if (this.folderItems[0]) { this.selectedFolderId = this.folderItems[0].value this.folderUpdated() diff --git a/client/components/widgets/PodcastDetailsEdit.vue b/client/components/widgets/PodcastDetailsEdit.vue index 0be67df32..215a5b2f1 100644 --- a/client/components/widgets/PodcastDetailsEdit.vue +++ b/client/components/widgets/PodcastDetailsEdit.vue @@ -34,8 +34,9 @@
-
+
+
@@ -70,6 +71,7 @@ export default { itunesId: null, itunesArtistId: null, explicit: false, + isAuthenticatedFeed: false, language: null, type: null }, @@ -241,6 +243,7 @@ export default { this.details.itunesArtistId = this.mediaMetadata.itunesArtistId || '' this.details.language = this.mediaMetadata.language || '' this.details.explicit = !!this.mediaMetadata.explicit + this.details.isAuthenticatedFeed = !!this.mediaMetadata.isAuthenticatedFeed this.details.type = this.mediaMetadata.type || 'episodic' this.newTags = [...(this.media.tags || [])] diff --git a/client/strings/en-us.json b/client/strings/en-us.json index fb2bcb281..5887aac85 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -366,6 +366,7 @@ "LabelExplicit": "Explicit", "LabelExplicitChecked": "Explicit (checked)", "LabelExplicitUnchecked": "Not Explicit (unchecked)", + "LabelAuthenticatedFeed": "Authenticated Feed", "LabelExportOPML": "Export OPML", "LabelFeedURL": "Feed URL", "LabelFetchingMetadata": "Fetching Metadata", diff --git a/server/migrations/v2.30.1-add-is-authenticated-feed.js b/server/migrations/v2.30.1-add-is-authenticated-feed.js new file mode 100644 index 000000000..319b3295e --- /dev/null +++ b/server/migrations/v2.30.1-add-is-authenticated-feed.js @@ -0,0 +1,68 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.30.1' +const migrationName = `${migrationVersion}-add-is-authenticated-feed` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration adds the isAuthenticatedFeed 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 } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + const DataTypes = queryInterface.sequelize.Sequelize.DataTypes + + // Check if column exists + const tableDescription = await queryInterface.describeTable('podcasts') + if (tableDescription.isAuthenticatedFeed) { + logger.info(`${loggerPrefix} column "isAuthenticatedFeed" already exists in "podcasts" table`) + } else { + // Add column + logger.info(`${loggerPrefix} adding column "isAuthenticatedFeed" to "podcasts" table`) + await queryInterface.addColumn('podcasts', 'isAuthenticatedFeed', { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false + }) + logger.info(`${loggerPrefix} added column "isAuthenticatedFeed" to "podcasts" table`) + } + + logger.info(`${loggerPrefix} UPGRADE COMPLETE: ${migrationName}`) +} + +/** + * This downward migration removes the isAuthenticatedFeed 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 } }) { + // Downwards migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + // Check if column exists + const tableDescription = await queryInterface.describeTable('podcasts') + if (!tableDescription.isAuthenticatedFeed) { + logger.info(`${loggerPrefix} column "isAuthenticatedFeed" does not exist in "podcasts" table`) + } else { + // Remove column + logger.info(`${loggerPrefix} removing column "isAuthenticatedFeed" from "podcasts" table`) + await queryInterface.removeColumn('podcasts', 'isAuthenticatedFeed') + logger.info(`${loggerPrefix} removed column "isAuthenticatedFeed" from "podcasts" table`) + } + + logger.info(`${loggerPrefix} DOWNGRADE COMPLETE: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/models/Podcast.js b/server/models/Podcast.js index a96e1dd02..b8874d15f 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.isAuthenticatedFeed + /** @type {boolean} */ this.autoDownloadEpisodes /** @type {string} */ this.autoDownloadSchedule @@ -104,6 +106,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, + isAuthenticatedFeed: !!payload.metadata.isAuthenticatedFeed, autoDownloadEpisodes: !!payload.autoDownloadEpisodes, autoDownloadSchedule: autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule, lastEpisodeCheck: new Date(), @@ -141,6 +144,7 @@ class Podcast extends Model { language: DataTypes.STRING, podcastType: DataTypes.STRING, explicit: DataTypes.BOOLEAN, + isAuthenticatedFeed: DataTypes.BOOLEAN, autoDownloadEpisodes: DataTypes.BOOLEAN, autoDownloadSchedule: DataTypes.STRING, @@ -252,6 +256,11 @@ class Podcast extends Model { hasUpdates = true } + if (payload.metadata.isAuthenticatedFeed !== undefined && payload.metadata.isAuthenticatedFeed !== this.isAuthenticatedFeed) { + this.isAuthenticatedFeed = !!payload.metadata.isAuthenticatedFeed + hasUpdates = true + } + if (Array.isArray(payload.metadata.genres) && !payload.metadata.genres.some((item) => typeof item !== 'string') && JSON.stringify(this.genres) !== JSON.stringify(payload.metadata.genres)) { this.genres = payload.metadata.genres this.changed('genres', true) @@ -407,6 +416,7 @@ class Podcast extends Model { itunesId: this.itunesId, itunesArtistId: this.itunesArtistId, explicit: this.explicit, + isAuthenticatedFeed: this.isAuthenticatedFeed, language: this.language, type: this.podcastType } diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index 80832cc77..17cc1c200 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -108,19 +108,33 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { // See: https://github.com/advplyr/audiobookshelf/issues/4401 (requires no iTMS user agent) const userAgents = ['audiobookshelf (+https://audiobookshelf.org; like iTMS)', 'audiobookshelf (+https://audiobookshelf.org)'] + let refererHeader = null + const feedURL = podcastEpisodeDownload.libraryItem?.media?.feedURL + const isAuthenticatedFeed = podcastEpisodeDownload.libraryItem?.media?.isAuthenticatedFeed || false + + if (feedURL && isAuthenticatedFeed) { + refererHeader = feedURL + } + let response = null let lastError = null for (const userAgent of userAgents) { try { + const headers = { + Accept: '*/*', + 'User-Agent': userAgent + } + + if (refererHeader) { + headers['Referer'] = refererHeader + } + response = await axios({ url: podcastEpisodeDownload.url, method: 'GET', responseType: 'stream', - headers: { - Accept: '*/*', - 'User-Agent': userAgent - }, + headers, timeout: global.PodcastDownloadTimeout })