Added support for authenticated podcast feeds

This commit is contained in:
Shaun Guimond 2025-12-01 05:34:55 +00:00
parent 329e3c7179
commit 66fee7d39d
6 changed files with 110 additions and 8 deletions

View file

@ -29,15 +29,18 @@
</div> </div>
</div> </div>
<div class="flex flex-wrap"> <div class="flex flex-wrap">
<div class="md:w-1/4 p-2"> <div class="w-full md:w-1/4 p-2">
<ui-dropdown :label="$strings.LabelPodcastType" v-model="podcast.type" :items="podcastTypes" small /> <ui-dropdown :label="$strings.LabelPodcastType" v-model="podcast.type" :items="podcastTypes" small />
</div> </div>
<div class="md:w-1/4 p-2"> <div class="w-full md:w-1/4 p-2">
<ui-text-input-with-label v-model="podcast.language" :label="$strings.LabelLanguage" /> <ui-text-input-with-label v-model="podcast.language" :label="$strings.LabelLanguage" />
</div> </div>
<div class="md:w-1/4 px-2 pt-7"> <div class="w-full md:w-1/4 px-2 pt-7">
<ui-checkbox v-model="podcast.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" /> <ui-checkbox v-model="podcast.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div> </div>
<div class="w-full md:w-1/4 px-2 pt-7">
<ui-checkbox v-model="podcast.isAuthenticatedFeed" :label="$strings.LabelAuthenticatedFeed" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div> </div>
<div class="p-2 w-full"> <div class="p-2 w-full">
<ui-textarea-with-label v-model="podcast.description" :label="$strings.LabelDescription" :rows="3" /> <ui-textarea-with-label v-model="podcast.description" :label="$strings.LabelDescription" :rows="3" />
@ -96,6 +99,7 @@ export default {
autoDownloadEpisodes: false, autoDownloadEpisodes: false,
language: '', language: '',
explicit: false, explicit: false,
isAuthenticatedFeed: false,
type: '' type: ''
} }
} }
@ -194,6 +198,7 @@ export default {
itunesArtistId: this.podcast.itunesArtistId, itunesArtistId: this.podcast.itunesArtistId,
language: this.podcast.language, language: this.podcast.language,
explicit: this.podcast.explicit, explicit: this.podcast.explicit,
isAuthenticatedFeed: this.podcast.isAuthenticatedFeed,
type: this.podcast.type type: this.podcast.type
}, },
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
@ -234,6 +239,7 @@ export default {
this.podcast.type = this._podcastData.type || this.feedMetadata.type || 'episodic' 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.explicit = this._podcastData.explicit || this.feedMetadata.explicit === 'yes' || this.feedMetadata.explicit == 'true'
this.podcast.isAuthenticatedFeed = this._podcastData.isAuthenticatedFeed || false
if (this.folderItems[0]) { if (this.folderItems[0]) {
this.selectedFolderId = this.folderItems[0].value this.selectedFolderId = this.folderItems[0].value
this.folderUpdated() this.folderUpdated()

View file

@ -34,8 +34,9 @@
<ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" trim-whitespace @input="handleInputChange" /> <ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" trim-whitespace @input="handleInputChange" />
</div> </div>
<div class="grow px-1 pt-6"> <div class="grow px-1 pt-6">
<div class="flex justify-center"> <div class="flex justify-center space-x-4">
<ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" @input="handleInputChange" /> <ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" @input="handleInputChange" />
<ui-checkbox v-model="details.isAuthenticatedFeed" :label="$strings.LabelAuthenticatedFeed" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" @input="handleInputChange" />
</div> </div>
</div> </div>
</div> </div>
@ -70,6 +71,7 @@ export default {
itunesId: null, itunesId: null,
itunesArtistId: null, itunesArtistId: null,
explicit: false, explicit: false,
isAuthenticatedFeed: false,
language: null, language: null,
type: null type: null
}, },
@ -241,6 +243,7 @@ export default {
this.details.itunesArtistId = this.mediaMetadata.itunesArtistId || '' this.details.itunesArtistId = this.mediaMetadata.itunesArtistId || ''
this.details.language = this.mediaMetadata.language || '' this.details.language = this.mediaMetadata.language || ''
this.details.explicit = !!this.mediaMetadata.explicit this.details.explicit = !!this.mediaMetadata.explicit
this.details.isAuthenticatedFeed = !!this.mediaMetadata.isAuthenticatedFeed
this.details.type = this.mediaMetadata.type || 'episodic' this.details.type = this.mediaMetadata.type || 'episodic'
this.newTags = [...(this.media.tags || [])] this.newTags = [...(this.media.tags || [])]

View file

@ -366,6 +366,7 @@
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelExplicitChecked": "Explicit (checked)", "LabelExplicitChecked": "Explicit (checked)",
"LabelExplicitUnchecked": "Not Explicit (unchecked)", "LabelExplicitUnchecked": "Not Explicit (unchecked)",
"LabelAuthenticatedFeed": "Authenticated Feed",
"LabelExportOPML": "Export OPML", "LabelExportOPML": "Export OPML",
"LabelFeedURL": "Feed URL", "LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata", "LabelFetchingMetadata": "Fetching Metadata",

View file

@ -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<void>} - 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<void>} - 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 }

View file

@ -44,6 +44,8 @@ class Podcast extends Model {
/** @type {boolean} */ /** @type {boolean} */
this.explicit this.explicit
/** @type {boolean} */ /** @type {boolean} */
this.isAuthenticatedFeed
/** @type {boolean} */
this.autoDownloadEpisodes this.autoDownloadEpisodes
/** @type {string} */ /** @type {string} */
this.autoDownloadSchedule this.autoDownloadSchedule
@ -104,6 +106,7 @@ class Podcast extends Model {
language: typeof payload.metadata.language === 'string' ? payload.metadata.language : null, language: typeof payload.metadata.language === 'string' ? payload.metadata.language : null,
podcastType: typeof payload.metadata.type === 'string' ? payload.metadata.type : null, podcastType: typeof payload.metadata.type === 'string' ? payload.metadata.type : null,
explicit: !!payload.metadata.explicit, explicit: !!payload.metadata.explicit,
isAuthenticatedFeed: !!payload.metadata.isAuthenticatedFeed,
autoDownloadEpisodes: !!payload.autoDownloadEpisodes, autoDownloadEpisodes: !!payload.autoDownloadEpisodes,
autoDownloadSchedule: autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule, autoDownloadSchedule: autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule,
lastEpisodeCheck: new Date(), lastEpisodeCheck: new Date(),
@ -141,6 +144,7 @@ class Podcast extends Model {
language: DataTypes.STRING, language: DataTypes.STRING,
podcastType: DataTypes.STRING, podcastType: DataTypes.STRING,
explicit: DataTypes.BOOLEAN, explicit: DataTypes.BOOLEAN,
isAuthenticatedFeed: DataTypes.BOOLEAN,
autoDownloadEpisodes: DataTypes.BOOLEAN, autoDownloadEpisodes: DataTypes.BOOLEAN,
autoDownloadSchedule: DataTypes.STRING, autoDownloadSchedule: DataTypes.STRING,
@ -252,6 +256,11 @@ class Podcast extends Model {
hasUpdates = true 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)) { 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.genres = payload.metadata.genres
this.changed('genres', true) this.changed('genres', true)
@ -407,6 +416,7 @@ class Podcast extends Model {
itunesId: this.itunesId, itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId, itunesArtistId: this.itunesArtistId,
explicit: this.explicit, explicit: this.explicit,
isAuthenticatedFeed: this.isAuthenticatedFeed,
language: this.language, language: this.language,
type: this.podcastType type: this.podcastType
} }

View file

@ -108,19 +108,33 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
// See: https://github.com/advplyr/audiobookshelf/issues/4401 (requires no iTMS user agent) // 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)'] 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 response = null
let lastError = null let lastError = null
for (const userAgent of userAgents) { for (const userAgent of userAgents) {
try { try {
const headers = {
Accept: '*/*',
'User-Agent': userAgent
}
if (refererHeader) {
headers['Referer'] = refererHeader
}
response = await axios({ response = await axios({
url: podcastEpisodeDownload.url, url: podcastEpisodeDownload.url,
method: 'GET', method: 'GET',
responseType: 'stream', responseType: 'stream',
headers: { headers,
Accept: '*/*',
'User-Agent': userAgent
},
timeout: global.PodcastDownloadTimeout timeout: global.PodcastDownloadTimeout
}) })