Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Improve RSS Manager adding Podcast Feed Subscriptions Health Check #2178

Open
wants to merge 35 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2b3c3cd
Add infra to handle with feed healthy
mfcar Sep 23, 2023
9872937
Creating a controller to return only the podcasts with feedURL
mfcar Sep 24, 2023
4de5b92
Merge branch 'master' into mf/rssInboundManager
mfcar Sep 30, 2023
7f5fde1
Fixing the rss incoming server side
mfcar Oct 1, 2023
2ada293
Feed Healthy indicator
mfcar Oct 1, 2023
aefcc44
RSS Manager code
mfcar Oct 3, 2023
309f5ef
Update the podcastmanager
mfcar Oct 3, 2023
56ff12e
Fix Podcast.js indentation
mfcar Oct 3, 2023
ecdafcd
Fix PodcastManager.js indentation
mfcar Oct 3, 2023
646f66d
Merge branch 'master' into mf/rssInboundManager
mfcar Oct 3, 2023
eb8e49e
Merge branch 'master' into mf/rssInboundManager
mfcar Dec 9, 2023
e9a1317
Merge branch 'master' into mf/rssInboundManager
mfcar Dec 17, 2023
47854f2
Merge branch 'master' into mf/rssInboundManager
mfcar Dec 24, 2023
136265f
Merge branch 'master' into mf/rssInboundManager
mfcar Dec 25, 2023
1f760a6
Merge branch 'master' into mf/rssInboundManager
mfcar Jan 1, 2024
c7b43bd
Merge branch 'master' into mf/rssInboundManager
mfcar Jan 3, 2024
f851b47
Merge branch 'master' into mf/rssInboundManager
mfcar Jan 12, 2024
e0b2465
Merge branch 'master' into mf/rssInboundManager
mfcar Jan 14, 2024
a6ffda0
Merge branch 'master' into mf/rssInboundManager
mfcar Jan 21, 2024
ad79f71
Merge branch 'master' into mf/rssInboundManager
mfcar Feb 20, 2024
2d98c27
Working on the rss
mfcar Feb 21, 2024
50ea58a
Merge branch 'refs/heads/master' into mf/rssInboundManager
mfcar May 11, 2024
a3ad19d
Fix strings, fix cover images and add tooltips
mfcar May 11, 2024
8017760
Invert template positions
mfcar May 11, 2024
8792a46
Fix language sort
mfcar May 11, 2024
00bfb5c
Remove commented code
mfcar May 11, 2024
9ff5f2f
Rename methods and fix save bug
mfcar May 11, 2024
7bd3d3f
Add title for opened feed tab
mfcar May 12, 2024
3c3835e
Fix key sorting
mfcar May 12, 2024
f2c9fd5
Merge branch 'refs/heads/master' into mf/rssInboundManager
mfcar May 18, 2024
2db98d3
Update Header strings
mfcar May 18, 2024
43accef
Update header string to maintain consistency.
mfcar May 18, 2024
e7c26ad
Fix lang file
mfcar May 18, 2024
31e50e4
Merge branch 'refs/heads/master' into mf/rssInboundManager
mfcar May 27, 2024
2d2c624
Merge branch 'refs/heads/master' into mf/rssInboundManager
mfcar Nov 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions client/components/widgets/FeedHealthyIndicator.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<template>
<p v-if="value" class="text-success">
<ui-tooltip direction="top"
:text="$strings.LabelFeedWorking">
<span class="material-icons text-2xl">cloud_done</span>
</ui-tooltip>
</p>
<p v-else class="text-error">
<ui-tooltip direction="top"
:text="$strings.LabelFeedNotWorking">
<span class="material-icons text-2xl">cloud_off</span>
</ui-tooltip>
</p>
</template>

<script>
export default {
props: {
value: Boolean
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
16 changes: 14 additions & 2 deletions client/components/widgets/PodcastDetailsEdit.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,15 @@
</div>
</div>

<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" :label="$strings.LabelRSSFeedURL" class="mt-2" @input="handleInputChange" />
<div class="flex mt-2">
<div class="w-full relative">
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" :label="$strings.LabelRSSFeedURL" class="mt-2" @input="handleInputChange" />
<div v-if="details.feedHealthy != null" class="material-icons absolute right-2 bottom-1 p-0.5">
<widgets-feed-healthy-indicator :value="details.feedHealthy"></widgets-feed-healthy-indicator>
</div>
</div>
</div>
<p v-if="details.lastSuccessfulFetchAt" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelFeedLastSuccessfulCheck }}: {{$dateDistanceFromNow(details.lastSuccessfulFetchAt)}}</p>

<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" @input="handleInputChange" />

Expand Down Expand Up @@ -71,7 +79,9 @@ export default {
itunesArtistId: null,
explicit: false,
language: null,
type: null
type: null,
feedHealthy: false,
lastSuccessfulFetchAt: null
},
newTags: []
}
Expand Down Expand Up @@ -242,6 +252,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 || [])]
},
Expand Down
367 changes: 298 additions & 69 deletions client/pages/config/rss-feeds.vue

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions client/pages/item/_id/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -474,16 +474,16 @@ export default {
return this.$toast.error(this.$strings.ToastNoRSSFeed)
}
this.fetchingRSSFeed = true
var payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => {
var payload = await this.$axios.get(`/api/podcasts/${this.libraryItemId}/feed`).catch((error) => {
console.error('Failed to get feed', error)
this.$toast.error(this.$strings.ToastPodcastGetFeedFailed)
return null
})
this.fetchingRSSFeed = false
if (!payload) return
if (!payload || !payload.data) return

console.log('Podcast feed', payload)
const podcastfeed = payload.podcast
console.log('Podcast feed', payload.data)
const podcastfeed = payload.data.podcast
if (!podcastfeed.episodes || !podcastfeed.episodes.length) {
this.$toast.info(this.$strings.ToastPodcastNoEpisodesInFeed)
return
Expand Down
16 changes: 16 additions & 0 deletions client/strings/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"ButtonCloseSession": "Close Open Session",
"ButtonCollections": "Collections",
"ButtonConfigureScanner": "Configure Scanner",
"ButtonCopyFeedURL": "Copy Feed URL",
"ButtonCreate": "Create",
"ButtonCreateBackup": "Create Backup",
"ButtonDelete": "Delete",
Expand All @@ -32,6 +33,7 @@
"ButtonEnable": "Enable",
"ButtonFireAndFail": "Fire and Fail",
"ButtonFireOnTest": "Fire onTest event",
"ButtonForceReCheckFeed": "Force Re-Check Feed",
"ButtonForceReScan": "Force Re-Scan",
"ButtonFullPath": "Full Path",
"ButtonHide": "Hide",
Expand Down Expand Up @@ -137,8 +139,10 @@
"HeaderEpisodes": "Episodes",
"HeaderEreaderDevices": "Ereader Devices",
"HeaderEreaderSettings": "Ereader Settings",
"HeaderExternalFeedURLHealthChecker": "External RSS Feed Health Check",
"HeaderFiles": "Files",
"HeaderFindChapters": "Find Chapters",
"HeaderHostedRSSFeeds": "ABS Hosted RSS Feed",
"HeaderIgnoredFiles": "Ignored Files",
"HeaderItemFiles": "Item Files",
"HeaderItemMetadataUtils": "Item Metadata Utils",
Expand Down Expand Up @@ -292,6 +296,7 @@
"LabelDeviceInfo": "Device Info",
"LabelDeviceIsAvailableTo": "Device is available to...",
"LabelDirectory": "Directory",
"LabelDisabled": "Disabled",
"LabelDiscFromFilename": "Disc from Filename",
"LabelDiscFromMetadata": "Disc from Metadata",
"LabelDiscover": "Discover",
Expand All @@ -314,6 +319,7 @@
"LabelEmailSettingsTestAddress": "Test Address",
"LabelEmbeddedCover": "Embedded Cover",
"LabelEnable": "Enable",
"LabelEnabled": "Enabled",
"LabelEncodingBackupLocation": "A backup of your original audio files will be stored in:",
"LabelEncodingChaptersNotEmbedded": "Chapters are not embedded in multi-track audiobooks.",
"LabelEncodingClearItemCache": "Make sure to periodically purge items cache.",
Expand All @@ -340,7 +346,14 @@
"LabelExplicitChecked": "Explicit (checked)",
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
"LabelExportOPML": "Export OPML",
"LabelFeedHealthy": "Feed Healthy",
"LabelFeedLastChecked": "Last Checked",
"LabelFeedLastSuccessfulCheck": "Last Successful Check",
"LabelFeedNextAutomaticCheck": "Next Automatic Check",
"LabelFeedNotWorking": "Feed is returning errors, check the logs for more information",
"LabelFeedShowOnlyUnhealthy": "Show only unhealthy",
"LabelFeedURL": "Feed URL",
"LabelFeedWorking": "Feed working as expected",
"LabelFetchingMetadata": "Fetching Metadata",
"LabelFile": "File",
"LabelFileBirthtime": "File Birthtime",
Expand Down Expand Up @@ -776,6 +789,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",
Expand Down Expand Up @@ -895,6 +909,7 @@
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..",
"PlaceholderSearchEpisode": "Search episode..",
"PlaceholderSearchTitle": "Search title..",
"StatsAuthorsAdded": "authors added",
"StatsBooksAdded": "books added",
"StatsBooksAdditional": "Some additions include…",
Expand All @@ -913,6 +928,7 @@
"StatsTopNarrators": "TOP NARRATORS",
"StatsTotalDuration": "With a total duration of…",
"StatsYearInReview": "YEAR IN REVIEW",
"ToastAccountUpdateFailed": "Failed to update account",
"ToastAccountUpdateSuccess": "Account updated",
"ToastAppriseUrlRequired": "Must enter an Apprise URL",
"ToastAsinRequired": "ASIN is required",
Expand Down
38 changes: 38 additions & 0 deletions server/controllers/PodcastController.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,44 @@ class PodcastController {
res.json({ podcast })
}

async getPodcastsWithExternalFeedsSubscriptions(req, res) {
const podcasts = await Database.podcastModel.getAllIWithFeedSubscriptions()
res.json({
podcasts
})
}

async checkPodcastFeed(req, res) {
const libraryItem = req.libraryItem
const podcast = await getPodcastFeed(libraryItem.media.metadata.feedUrl)

if (!podcast) {
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.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.' });
}
}

/**
* POST: /api/podcasts/opml
*
Expand Down
35 changes: 31 additions & 4 deletions server/managers/PodcastManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ class PodcastManager {
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)

var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)
let newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)
Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`)

if (!newEpisodes) {
Expand All @@ -281,13 +281,18 @@ class PodcastManager {
} else {
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
}
libraryItem.media.metadata.feedHealthy = false
} else if (newEpisodes.length) {
delete this.failedCheckMap[libraryItem.id]
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
this.downloadPodcastEpisodes(libraryItem, newEpisodes, true)
libraryItem.media.metadata.lastSuccessfulFetchAt = Date.now()
libraryItem.media.metadata.feedHealthy = true
} else {
delete this.failedCheckMap[libraryItem.id]
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
libraryItem.media.metadata.lastSuccessfulFetchAt = Date.now()
libraryItem.media.metadata.feedHealthy = true
}

libraryItem.media.lastEpisodeCheck = Date.now()
Expand All @@ -304,7 +309,7 @@ class PodcastManager {
}
const feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
if (!feed?.episodes) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed)
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id}, URL: ${podcastLibraryItem.media.metadata.feedUrl})`, feed)
return false
}

Expand All @@ -321,12 +326,18 @@ class PodcastManager {
async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) {
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck, maxEpisodesToDownload)
if (newEpisodes.length) {
let newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck, maxEpisodesToDownload)
if (!newEpisodes) {
libraryItem.media.metadata.feedHealthy = false
} else if (newEpisodes.length) {
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
this.downloadPodcastEpisodes(libraryItem, newEpisodes, false)
libraryItem.media.metadata.lastSuccessfulFetchAt = Date.now()
libraryItem.media.metadata.feedHealthy = true
} else {
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.metadata.title}"`)
libraryItem.media.metadata.lastSuccessfulFetchAt = Date.now()
libraryItem.media.metadata.feedHealthy = true
}

libraryItem.media.lastEpisodeCheck = Date.now()
Expand All @@ -337,6 +348,22 @@ class PodcastManager {
return newEpisodes
}

async setFeedHealthStatus(podcastId, isHealthy) {
const podcast = await Database.podcastModel.findByPk(podcastId)

if (!podcast) return

podcast.feedHealthy = isHealthy
if (isHealthy) {
podcast.lastSuccessfulFetchAt = Date.now()
}
podcast.lastEpisodeCheck = Date.now()
podcast.updatedAt = Date.now()
await podcast.save()

return {lastEpisodeCheck: podcast.lastEpisodeCheck, lastSuccessfulFetchAt: podcast.lastSuccessfulFetchAt, feedHealthy: podcast.feedHealthy}
}

async findEpisode(rssFeedUrl, searchTitle) {
const feed = await getPodcastFeed(rssFeedUrl).catch(() => {
return null
Expand Down
24 changes: 20 additions & 4 deletions server/models/Podcast.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ class Podcast extends Model {
this.createdAt
/** @type {Date} */
this.updatedAt
/** @type {Date} */
this.lastSuccessfulFetchAt
/** @type {boolean} */
this.feedHealthy
}

static getOldPodcast(libraryItemExpanded) {
Expand All @@ -78,7 +82,9 @@ class Podcast extends Model {
itunesArtistId: podcastExpanded.itunesArtistId,
explicit: podcastExpanded.explicit,
language: podcastExpanded.language,
type: podcastExpanded.podcastType
type: podcastExpanded.podcastType,
lastSuccessfulFetchAt: podcastExpanded.lastSuccessfulFetchAt?.valueOf() || null,
feedHealthy: !!podcastExpanded.feedHealthy
},
coverPath: podcastExpanded.coverPath,
tags: podcastExpanded.tags,
Expand Down Expand Up @@ -115,10 +121,18 @@ class Podcast extends Model {
maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload,
coverPath: oldPodcast.coverPath,
tags: oldPodcast.tags,
genres: oldPodcastMetadata.genres
genres: oldPodcastMetadata.genres,
lastSuccessfulFetchAt: oldPodcastMetadata.lastSuccessfulFetchAt,
feedHealthy: !!oldPodcastMetadata.feedHealthy
}
}

static async getAllIWithFeedSubscriptions() {
const podcasts = await this.findAll()
const podcastsFiltered = podcasts.filter(p => p.dataValues.feedURL !== null);
return podcastsFiltered.map(p => this.getOldPodcast({media: p.dataValues}))
}

getAbsMetadataJson() {
return {
tags: this.tags || [],
Expand Down Expand Up @@ -171,8 +185,10 @@ class Podcast extends Model {
maxNewEpisodesToDownload: DataTypes.INTEGER,
coverPath: DataTypes.STRING,
tags: DataTypes.JSON,
genres: DataTypes.JSON
},
genres: DataTypes.JSON,
lastSuccessfulFetchAt: DataTypes.DATE,
feedHealthy: DataTypes.BOOLEAN
},
{
sequelize,
modelName: 'podcast'
Expand Down
14 changes: 12 additions & 2 deletions server/objects/metadata/PodcastMetadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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() {
Expand All @@ -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
}
}

Expand All @@ -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
}
}

Expand Down Expand Up @@ -107,6 +115,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) {
Expand Down
Loading
Loading