diff --git a/client/components/widgets/PodcastDetailsEdit.vue b/client/components/widgets/PodcastDetailsEdit.vue index 4c2fd739a..c93d7f84c 100644 --- a/client/components/widgets/PodcastDetailsEdit.vue +++ b/client/components/widgets/PodcastDetailsEdit.vue @@ -10,7 +10,15 @@ - +
+
+ +
+ +
+
+
+

{{ $strings.LabelFeedLastSuccessfulCheck }}: {{$dateDistanceFromNow(details.lastSuccessfulFetchAt)}}

@@ -71,7 +79,9 @@ export default { itunesArtistId: null, explicit: false, language: null, - type: null + type: null, + feedHealthy: false, + lastSuccessfulFetchAt: null }, newTags: [] } @@ -229,6 +239,8 @@ export default { this.details.language = this.mediaMetadata.language || '' this.details.explicit = !!this.mediaMetadata.explicit this.details.type = this.mediaMetadata.type || 'episodic' + this.details.feedHealthy = !!this.mediaMetadata.feedHealthy + this.details.lastSuccessfulFetchAt = this.mediaMetadata.lastSuccessfulFetchAt || null this.newTags = [...(this.media.tags || [])] }, diff --git a/client/pages/config/rss-feeds.vue b/client/pages/config/rss-feeds.vue index 28dba6704..d62cf6d08 100644 --- a/client/pages/config/rss-feeds.vue +++ b/client/pages/config/rss-feeds.vue @@ -1,61 +1,180 @@ @@ -63,9 +182,18 @@ export default { data() { return { + showIncomingFeedsView: false, showFeedModal: false, selectedFeed: null, - feeds: [] + feeds: [], + incomingFeeds: [], + feedsSearch: null, + feedsSearchTimeout: null, + feedsSearchText: null, + incomingFeedsSearch: null, + incomingFeedsSearchTimeout: null, + incomingFeedsSearchText: null, + incomingFeedShowOnlyUnhealthy: false, } }, computed: { @@ -74,13 +202,63 @@ export default { }, timeFormat() { return this.$store.state.serverSettings.timeFormat - } + }, + noCoverUrl() { + return `${this.$config.routerBasePath}/Logo.png` + }, + bookCoverAspectRatio() { + return this.$store.getters['libraries/getBookCoverAspectRatio'] + }, + feedsList() { + return this.feeds.filter((feed) => { + if (!this.feedsSearchText) return true + return feed?.meta?.title?.toLowerCase().includes(this.feedsSearchText) || feed?.slug?.toLowerCase().includes(this.feedsSearchText) + }) + }, + incomingFeedsSorted() { + return this.incomingFeedShowOnlyUnhealthy + ? this.incomingFeeds.filter(incomingFeed => !incomingFeed.metadata.feedHealthy) + : this.incomingFeeds; + }, + incomingFeedsList() { + return this.incomingFeedsSorted.filter((incomingFeed) => { + if (!this.incomingFeedsSearchText) return true + if (this.incomingFeedShowOnlyUnhealthy && incomingFeed?.metadata?.feedHealthy) return false + return incomingFeed?.metadata?.title?.toLowerCase().includes(this.incomingFeedsSearchText) || + incomingFeed?.metadata?.feedUrl?.toLowerCase().includes(this.incomingFeedsSearchText) + }) + }, }, methods: { + feedsSubmit() {}, + feedsInputUpdate() { + clearTimeout(this.feedsSearchTimeout) + this.feedsSearchTimeout = setTimeout(() => { + if (!this.feedsSearch || !this.feedsSearch.trim()) { + this.feedsSearchText = '' + return + } + this.feedsSearchText = this.feedsSearch.toLowerCase().trim() + }, 500) + }, + incomingFeedsSubmit() {}, + incomingFeedsInputUpdate() { + clearTimeout(this.incomingFeedsSearchTimeout) + this.incomingFeedsSearchTimeout = setTimeout(() => { + if (!this.incomingFeedsSearch || !this.incomingFeedsSearch.trim()) { + this.incomingFeedsSearchText = '' + return + } + this.incomingFeedsSearchText = this.incomingFeedsSearch.toLowerCase().trim() + }, 500) + }, showFeed(feed) { this.selectedFeed = feed this.showFeedModal = true }, + copyToClipboard(str) { + this.$copyToClipboard(str, this) + }, deleteFeedClick(feed) { const payload = { message: this.$strings.MessageConfirmCloseFeed, @@ -96,19 +274,19 @@ export default { deleteFeed(feed) { this.processing = true this.$axios - .$post(`/api/feeds/${feed.id}/close`) - .then(() => { - this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess) - this.show = false - this.loadFeeds() - }) - .catch((error) => { - console.error('Failed to close RSS feed', error) - this.$toast.error(this.$strings.ToastRSSFeedCloseFailed) - }) - .finally(() => { - this.processing = false - }) + .$post(`/api/feeds/${feed.id}/close`) + .then(() => { + this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess) + this.show = false + this.loadFeeds() + }) + .catch((error) => { + console.error('Failed to close RSS feed', error) + this.$toast.error(this.$strings.ToastRSSFeedCloseFailed) + }) + .finally(() => { + this.processing = false + }) }, getEntityType(entityType) { if (entityType === 'libraryItem') return this.$strings.LabelItem @@ -117,9 +295,40 @@ export default { return this.$strings.LabelUnknown }, coverUrl(feed) { - if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png` + if (!feed.coverPath) return this.noCoverUrl return `${feed.feedUrl}/cover` }, + nextRun(cronExpression) { + if (!cronExpression) return '' + const parsed = this.$getNextScheduledDate(cronExpression) + return this.$formatJsDatetime(parsed, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) || '' + }, + async forceRecheckFeed(podcast) { + podcast.isLoading = true + let podcastResult; + + try { + podcastResult = await this.$axios.$get(`/api/podcasts/${podcast.id}/check-feed-url`) + + if (!podcastResult?.feedHealthy) { + this.$toast.error('Podcast feed url is not healthy') + } else { + this.$toast.success('Podcast feed url is healthy') + } + + podcast.lastEpisodeCheck = Date.parse(podcastResult.lastEpisodeCheck) + if (podcastResult.lastSuccessfulFetchAt) { + podcast.metadata.lastSuccessfulFetchAt = Date.parse(podcastResult.lastSuccessfulFetchAt) + } + podcast.metadata.feedHealthy = podcastResult.feedHealthy + } catch (error) { + console.error('Podcast feed url is not healthy', error) + this.$toast.error('Podcast feed url is not healthy') + podcastResult = null + } finally { + podcast.isLoading = false + } + }, async loadFeeds() { const data = await this.$axios.$get(`/api/feeds`).catch((err) => { console.error('Failed to load RSS feeds', err) @@ -131,8 +340,21 @@ export default { } this.feeds = data.feeds }, + async loadIncomingFeeds() { + const data = await this.$axios.$get(`/api/podcasts/incomingFeeds`).catch((err) => { + console.error('Failed to load incoming RSS feeds', err) + return null + }) + if (!data) { + this.$toast.error('Failed to load incoming RSS feeds') + return + } + + this.incomingFeeds = data.podcasts.map(podcast => ({...podcast, isLoading: false})); + }, init() { this.loadFeeds() + this.loadIncomingFeeds() } }, mounted() { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index ae018d4bb..92bd6337e 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -17,6 +17,7 @@ "ButtonCloseFeed": "Close Feed", "ButtonCollections": "Collections", "ButtonConfigureScanner": "Configure Scanner", + "ButtonCopyFeedURL": "Copy Feed URL", "ButtonCreate": "Create", "ButtonCreateBackup": "Create Backup", "ButtonDelete": "Delete", @@ -24,6 +25,7 @@ "ButtonEdit": "Edit", "ButtonEditChapters": "Edit Chapters", "ButtonEditPodcast": "Edit Podcast", + "ButtonForceReCheckFeed": "Force Re-Check Feed", "ButtonForceReScan": "Force Re-Scan", "ButtonFullPath": "Full Path", "ButtonHide": "Hide", @@ -246,7 +248,14 @@ "LabelEpisodeType": "Episode Type", "LabelExample": "Example", "LabelExplicit": "Explicit", + "LabelFeedHealthy": "Feed Healthy", "LabelFeedURL": "Feed URL", + "LabelFeedLastChecked": "Last Checked", + "LabelFeedLastSuccessfulCheck": "Last Successful Check", + "LabelFeedShowOnlyUnhealthy": "Show only unhealthy", + "LabelFeedNextAutomaticCheck": "Next Automatic Check", + "LabelFeedNotWorking": "Feed is returning errors, check the logs for more information", + "LabelFeedWorking": "Feed working as expected", "LabelFile": "File", "LabelFileBirthtime": "File Birthtime", "LabelFileModified": "File Modified", @@ -572,6 +581,7 @@ "MessageMatchBooksDescription": "will attempt to match books in the library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.", "MessageNoAudioTracks": "No audio tracks", "MessageNoAuthors": "No Authors", + "MessageNoAvailable": "N/A", "MessageNoBackups": "No Backups", "MessageNoBookmarks": "No Bookmarks", "MessageNoChapters": "No Chapters", @@ -642,6 +652,7 @@ "PlaceholderNewPlaylist": "New playlist name", "PlaceholderSearch": "Search..", "PlaceholderSearchEpisode": "Search episode..", + "PlaceholderSearchTitle": "Search title..", "ToastAccountUpdateFailed": "Failed to update account", "ToastAccountUpdateSuccess": "Account updated", "ToastAuthorImageRemoveFailed": "Failed to remove image", @@ -713,4 +724,4 @@ "ToastSocketFailedToConnect": "Socket failed to connect", "ToastUserDeleteFailed": "Failed to delete user", "ToastUserDeleteSuccess": "User deleted" -} \ No newline at end of file +} diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 6f1138fb5..0b16ce719 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -127,14 +127,32 @@ class PodcastController { const podcast = await getPodcastFeed(libraryItem.media.metadata.feedUrl) if (!podcast) { - this.podcastManager.setFeedHealthStatus(libraryItem, false) + this.podcastManager.setFeedHealthStatus(libraryItem.media.id, false) return res.status(404).send('Podcast RSS feed request failed or invalid response data') } - this.podcastManager.setFeedHealthStatus(libraryItem, true) + this.podcastManager.setFeedHealthStatus(libraryItem.media.id, true) res.json({ podcast }) } + async checkPodcastFeedUrl(req, res) { + const podcastId = req.params.id; + + try { + const podcast = await Database.podcastModel.findByPk(req.params.id) + + const podcastResult = await getPodcastFeed(podcast.feedURL); + const podcastNewStatus = await this.podcastManager.setFeedHealthStatus(podcastId, !!podcastResult); + + Logger.info(podcastNewStatus); + + return res.json(podcastNewStatus); + } catch (error) { + Logger.error(`[PodcastController] checkPodcastFeed: Error checking podcast feed for podcast ${podcastId}`, error) + res.status(500).json({ error: 'An error occurred while checking the podcast feed.' }); + } + } + async getFeedsFromOPMLText(req, res) { if (!req.body.opmlText) { return res.sendStatus(400) diff --git a/server/models/Podcast.js b/server/models/Podcast.js index a1c58d4bb..d5dd5c65a 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -1,170 +1,171 @@ -const { DataTypes, Model } = require('sequelize') +const {DataTypes, Model} = require('sequelize') class Podcast extends Model { - constructor(values, options) { - super(values, options) + constructor(values, options) { + super(values, options) - /** @type {string} */ - this.id - /** @type {string} */ - this.title - /** @type {string} */ - this.titleIgnorePrefix - /** @type {string} */ - this.author - /** @type {string} */ - this.releaseDate - /** @type {string} */ - this.feedURL - /** @type {string} */ - this.imageURL - /** @type {string} */ - this.description - /** @type {string} */ - this.itunesPageURL - /** @type {string} */ - this.itunesId - /** @type {string} */ - this.itunesArtistId - /** @type {string} */ - this.language - /** @type {string} */ - this.podcastType - /** @type {boolean} */ - this.explicit - /** @type {boolean} */ - this.autoDownloadEpisodes - /** @type {string} */ - this.autoDownloadSchedule - /** @type {Date} */ - this.lastEpisodeCheck - /** @type {number} */ - this.maxEpisodesToKeep - /** @type {string} */ - this.coverPath - /** @type {string[]} */ - this.tags - /** @type {string[]} */ - this.genres - /** @type {Date} */ - this.createdAt - /** @type {Date} */ - this.updatedAt - /** @type {Date} */ - this.lastSuccessfulFetchAt - /** @type {boolean} */ - this.feedHealthy - } - - static getOldPodcast(libraryItemExpanded) { - const podcastExpanded = libraryItemExpanded.media - const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index) - return { - id: podcastExpanded.id, - libraryItemId: libraryItemExpanded.id, - metadata: { - title: podcastExpanded.title, - author: podcastExpanded.author, - description: podcastExpanded.description, - releaseDate: podcastExpanded.releaseDate, - genres: podcastExpanded.genres, - feedUrl: podcastExpanded.feedURL, - imageUrl: podcastExpanded.imageURL, - itunesPageUrl: podcastExpanded.itunesPageURL, - itunesId: podcastExpanded.itunesId, - itunesArtistId: podcastExpanded.itunesArtistId, - explicit: podcastExpanded.explicit, - language: podcastExpanded.language, - type: podcastExpanded.podcastType, - lastSuccessfulFetchAt: podcastExpanded.lastSuccessfulFetchAt?.valueOf() || null, - feedHealthy: !!podcastExpanded.feedHealthy - }, - coverPath: podcastExpanded.coverPath, - tags: podcastExpanded.tags, - episodes: podcastEpisodes || [], - autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes, - autoDownloadSchedule: podcastExpanded.autoDownloadSchedule, - lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null, - maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep, - maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload + /** @type {string} */ + this.id + /** @type {string} */ + this.title + /** @type {string} */ + this.titleIgnorePrefix + /** @type {string} */ + this.author + /** @type {string} */ + this.releaseDate + /** @type {string} */ + this.feedURL + /** @type {string} */ + this.imageURL + /** @type {string} */ + this.description + /** @type {string} */ + this.itunesPageURL + /** @type {string} */ + this.itunesId + /** @type {string} */ + this.itunesArtistId + /** @type {string} */ + this.language + /** @type {string} */ + this.podcastType + /** @type {boolean} */ + this.explicit + /** @type {boolean} */ + this.autoDownloadEpisodes + /** @type {string} */ + this.autoDownloadSchedule + /** @type {Date} */ + this.lastEpisodeCheck + /** @type {number} */ + this.maxEpisodesToKeep + /** @type {string} */ + this.coverPath + /** @type {string[]} */ + this.tags + /** @type {string[]} */ + this.genres + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + /** @type {Date} */ + this.lastSuccessfulFetchAt + /** @type {boolean} */ + this.feedHealthy } - } - static getFromOld(oldPodcast) { - const oldPodcastMetadata = oldPodcast.metadata - return { - id: oldPodcast.id, - title: oldPodcastMetadata.title, - titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix, - author: oldPodcastMetadata.author, - releaseDate: oldPodcastMetadata.releaseDate, - feedURL: oldPodcastMetadata.feedUrl, - imageURL: oldPodcastMetadata.imageUrl, - description: oldPodcastMetadata.description, - itunesPageURL: oldPodcastMetadata.itunesPageUrl, - itunesId: oldPodcastMetadata.itunesId, - itunesArtistId: oldPodcastMetadata.itunesArtistId, - language: oldPodcastMetadata.language, - podcastType: oldPodcastMetadata.type, - explicit: !!oldPodcastMetadata.explicit, - autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes, - autoDownloadSchedule: oldPodcast.autoDownloadSchedule, - lastEpisodeCheck: oldPodcast.lastEpisodeCheck, - maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep, - maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload, - coverPath: oldPodcast.coverPath, - tags: oldPodcast.tags, - genres: oldPodcastMetadata.genres, - lastSuccessfulFetchAt: oldPodcastMetadata.lastSuccessfulFetchAt, - feedHealthy: !!oldPodcastMetadata.feedHealthy + static getOldPodcast(libraryItemExpanded) { + const podcastExpanded = libraryItemExpanded.media + const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index) + return { + id: podcastExpanded.id, + libraryItemId: libraryItemExpanded.id, + metadata: { + title: podcastExpanded.title, + author: podcastExpanded.author, + description: podcastExpanded.description, + releaseDate: podcastExpanded.releaseDate, + genres: podcastExpanded.genres, + feedUrl: podcastExpanded.feedURL, + imageUrl: podcastExpanded.imageURL, + itunesPageUrl: podcastExpanded.itunesPageURL, + itunesId: podcastExpanded.itunesId, + itunesArtistId: podcastExpanded.itunesArtistId, + explicit: podcastExpanded.explicit, + language: podcastExpanded.language, + type: podcastExpanded.podcastType, + lastSuccessfulFetchAt: podcastExpanded.lastSuccessfulFetchAt?.valueOf() || null, + feedHealthy: !!podcastExpanded.feedHealthy + }, + coverPath: podcastExpanded.coverPath, + tags: podcastExpanded.tags, + episodes: podcastEpisodes || [], + autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes, + autoDownloadSchedule: podcastExpanded.autoDownloadSchedule, + lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null, + maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep, + maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload + } } - } - static async getAllIncomingFeeds(){ - const podcasts = await this.findAll() - return podcasts.map(p => this.getFromOld({metadata: p.dataValues})) - } + static getFromOld(oldPodcast) { + const oldPodcastMetadata = oldPodcast.metadata + return { + id: oldPodcast.id, + title: oldPodcastMetadata.title, + titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix, + author: oldPodcastMetadata.author, + releaseDate: oldPodcastMetadata.releaseDate, + feedURL: oldPodcastMetadata.feedUrl, + imageURL: oldPodcastMetadata.imageUrl, + description: oldPodcastMetadata.description, + itunesPageURL: oldPodcastMetadata.itunesPageUrl, + itunesId: oldPodcastMetadata.itunesId, + itunesArtistId: oldPodcastMetadata.itunesArtistId, + language: oldPodcastMetadata.language, + podcastType: oldPodcastMetadata.type, + explicit: !!oldPodcastMetadata.explicit, + autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes, + autoDownloadSchedule: oldPodcast.autoDownloadSchedule, + lastEpisodeCheck: oldPodcast.lastEpisodeCheck, + maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep, + maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload, + coverPath: oldPodcast.coverPath, + tags: oldPodcast.tags, + genres: oldPodcastMetadata.genres, + lastSuccessfulFetchAt: oldPodcastMetadata.lastSuccessfulFetchAt, + feedHealthy: !!oldPodcastMetadata.feedHealthy + } + } - /** - * Initialize model - * @param {import('../Database').sequelize} sequelize - */ - static init(sequelize) { - super.init({ - id: { - type: DataTypes.UUID, - defaultValue: DataTypes.UUIDV4, - primaryKey: true - }, - title: DataTypes.STRING, - titleIgnorePrefix: DataTypes.STRING, - author: DataTypes.STRING, - releaseDate: DataTypes.STRING, - feedURL: DataTypes.STRING, - imageURL: DataTypes.STRING, - description: DataTypes.TEXT, - itunesPageURL: DataTypes.STRING, - itunesId: DataTypes.STRING, - itunesArtistId: DataTypes.STRING, - language: DataTypes.STRING, - podcastType: DataTypes.STRING, - explicit: DataTypes.BOOLEAN, + static async getAllIncomingFeeds() { + const podcasts = await this.findAll() + const podcastsFiltered = podcasts.filter(p => p.dataValues.feedURL !== null); + return podcastsFiltered.map(p => this.getOldPodcast({media: p.dataValues})) + } - autoDownloadEpisodes: DataTypes.BOOLEAN, - autoDownloadSchedule: DataTypes.STRING, - lastEpisodeCheck: DataTypes.DATE, - maxEpisodesToKeep: DataTypes.INTEGER, - maxNewEpisodesToDownload: DataTypes.INTEGER, - coverPath: DataTypes.STRING, - tags: DataTypes.JSON, - genres: DataTypes.JSON, - lastSuccessfulFetchAt: DataTypes.DATE, - feedHealthy: DataTypes.BOOLEAN - }, { - sequelize, - modelName: 'podcast' - }) - } + /** + * Initialize model + * @param {import('../Database').sequelize} sequelize + */ + static init(sequelize) { + super.init({ + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true + }, + title: DataTypes.STRING, + titleIgnorePrefix: DataTypes.STRING, + author: DataTypes.STRING, + releaseDate: DataTypes.STRING, + feedURL: DataTypes.STRING, + imageURL: DataTypes.STRING, + description: DataTypes.TEXT, + itunesPageURL: DataTypes.STRING, + itunesId: DataTypes.STRING, + itunesArtistId: DataTypes.STRING, + language: DataTypes.STRING, + podcastType: DataTypes.STRING, + explicit: DataTypes.BOOLEAN, + + autoDownloadEpisodes: DataTypes.BOOLEAN, + autoDownloadSchedule: DataTypes.STRING, + lastEpisodeCheck: DataTypes.DATE, + maxEpisodesToKeep: DataTypes.INTEGER, + maxNewEpisodesToDownload: DataTypes.INTEGER, + coverPath: DataTypes.STRING, + tags: DataTypes.JSON, + genres: DataTypes.JSON, + lastSuccessfulFetchAt: DataTypes.DATE, + feedHealthy: DataTypes.BOOLEAN + }, { + sequelize, + modelName: 'podcast' + }) + } } module.exports = Podcast diff --git a/server/objects/metadata/PodcastMetadata.js b/server/objects/metadata/PodcastMetadata.js index 2c371c6cb..3655d78c7 100644 --- a/server/objects/metadata/PodcastMetadata.js +++ b/server/objects/metadata/PodcastMetadata.js @@ -16,6 +16,8 @@ class PodcastMetadata { this.explicit = false this.language = null this.type = null + this.lastSuccessfulFetchAt = null + this.feedHealthy = null if (metadata) { this.construct(metadata) @@ -36,6 +38,8 @@ class PodcastMetadata { this.explicit = metadata.explicit this.language = metadata.language || null this.type = metadata.type || 'episodic' + this.lastSuccessfulFetchAt = metadata.lastSuccessfulFetchAt || null + this.feedHealthy = metadata.feedHealthy || null } toJSON() { @@ -52,7 +56,9 @@ class PodcastMetadata { itunesArtistId: this.itunesArtistId, explicit: this.explicit, language: this.language, - type: this.type + type: this.type, + lastSuccessfulFetchAt: this.lastSuccessfulFetchAt, + feedHealthy: this.feedHealthy } } @@ -71,7 +77,9 @@ class PodcastMetadata { itunesArtistId: this.itunesArtistId, explicit: this.explicit, language: this.language, - type: this.type + type: this.type, + lastSuccessfulFetchAt: this.lastSuccessfulFetchAt, + feedHealthy: this.feedHealthy } } @@ -120,6 +128,8 @@ class PodcastMetadata { if (mediaMetadata.genres && mediaMetadata.genres.length) { this.genres = [...mediaMetadata.genres] } + this.lastSuccessfulFetchAt = mediaMetadata.lastSuccessfulFetchAt || null + this.feedHealthy = mediaMetadata.feedHealthy || null } update(payload) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index f58c0d3ae..9ee2c75be 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -229,6 +229,7 @@ class ApiRouter { this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this)) this.router.get('/podcasts/incomingFeeds', PodcastController.getPodcastsWithIncomingFeeds.bind(this)) this.router.get('/podcasts/:id/feed', PodcastController.middleware.bind(this), PodcastController.checkPodcastFeed.bind(this)) + this.router.get('/podcasts/:id/check-feed-url', PodcastController.checkPodcastFeedUrl.bind(this)) this.router.post('/podcasts/opml', PodcastController.getFeedsFromOPMLText.bind(this)) this.router.get('/podcasts/:id/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this)) this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))