This commit is contained in:
Marcos Carvalho 2024-12-07 18:49:46 -08:00 committed by GitHub
commit 4682ee919f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 466 additions and 85 deletions

View File

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

View File

@ -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" />
@ -71,7 +79,9 @@ export default {
itunesArtistId: null,
explicit: false,
language: null,
type: null
type: null,
feedHealthy: false,
lastSuccessfulFetchAt: null
},
newTags: []
}
@ -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 || [])]
},

View File

@ -9,61 +9,187 @@
</ui-tooltip>
</template>
<div v-if="feeds.length" class="block max-w-full pt-2">
<table class="rssFeedsTable text-xs">
<tr class="bg-primary bg-opacity-40 h-12">
<th class="w-16 min-w-16"></th>
<th class="w-48 max-w-64 min-w-24 text-left truncate">{{ $strings.LabelTitle }}</th>
<th class="w-48 min-w-24 text-left hidden xl:table-cell">{{ $strings.LabelSlug }}</th>
<th class="w-24 min-w-16 text-left hidden md:table-cell">{{ $strings.LabelType }}</th>
<th class="w-16 min-w-16 text-center">{{ $strings.HeaderEpisodes }}</th>
<th class="w-16 min-w-16 text-center hidden lg:table-cell">{{ $strings.LabelRSSFeedPreventIndexing }}</th>
<th class="w-48 min-w-24 flex-grow hidden md:table-cell">{{ $strings.LabelLastUpdate }}</th>
<th class="w-16 text-left"></th>
</tr>
<div class="w-full py-2">
<div class="flex -mb-px">
<div
class="w-1/2 h-8 rounded-tl-md relative border border-black-200 flex items-center justify-center cursor-pointer"
:class="showOpenedFeedsView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'"
@click="showOpenedFeedsView = true">
<p class="text-sm">{{$strings.HeaderHostedRSSFeeds}}</p>
</div>
<div
class="w-1/2 h-8 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px cursor-pointer"
:class="!showOpenedFeedsView ? 'text-white bg-bg hover:bg-opacity-60 border-b-bg' : 'text-gray-400 hover:text-gray-300 bg-primary bg-opacity-70 hover:bg-opacity-60'"
@click="showOpenedFeedsView = false">
<p class="text-sm">{{$strings.HeaderExternalFeedURLHealthChecker}}</p>
</div>
</div>
<div class="px-2 py-4 md:p-4 border border-black-200 rounded-b-md mr-px" style="min-height: 280px">
<template v-if="showOpenedFeedsView">
<div v-if="feeds.length" class="block max-w-full pt-2">
<form @submit.prevent="feedsSubmit" class="flex flex-grow">
<ui-text-input v-model="feedsSearch" @input="feedsInputUpdate" type="search" :placeholder="$strings.PlaceholderSearchTitle" class="flex-grow mb-3 text-sm md:text-base" />
</form>
<tr v-for="feed in feeds" :key="feed.id" class="cursor-pointer h-12" @click="showFeed(feed)">
<!-- -->
<td>
<img :src="coverUrl(feed)" class="h-full w-full" />
</td>
<!-- -->
<td class="w-48 max-w-64 min-w-24 text-left truncate">
<p class="truncate">{{ feed.meta.title }}</p>
</td>
<!-- -->
<td class="hidden xl:table-cell">
<p class="truncate">{{ feed.slug }}</p>
</td>
<!-- -->
<td class="hidden md:table-cell">
<p class="">{{ getEntityType(feed.entityType) }}</p>
</td>
<!-- -->
<td class="text-center">
<p class="">{{ feed.episodes.length }}</p>
</td>
<!-- -->
<td class="text-center leading-none hidden lg:table-cell">
<p v-if="feed.meta.preventIndexing" class="">
<span class="material-symbols text-2xl">check</span>
</p>
</td>
<!-- -->
<td class="text-center hidden md:table-cell">
<ui-tooltip v-if="feed.updatedAt" direction="top" :text="$formatDatetime(feed.updatedAt, dateFormat, timeFormat)">
<p class="text-gray-200">{{ $dateDistanceFromNow(feed.updatedAt) }}</p>
</ui-tooltip>
</td>
<!-- -->
<td class="text-center">
<ui-icon-btn icon="delete" class="mx-0.5" :size="7" bg-color="error" outlined @click.stop="deleteFeedClick(feed)" />
</td>
</tr>
</table>
<table class="rssFeedsTable text-xs">
<tr class="bg-primary bg-opacity-40 h-12">
<th class="w-16 min-w-16"></th>
<th class="w-48 max-w-64 min-w-24 text-left truncate">{{ $strings.LabelTitle }}</th>
<th class="w-48 min-w-24 text-left hidden xl:table-cell">{{ $strings.LabelSlug }}</th>
<th class="w-24 min-w-16 text-left hidden md:table-cell">{{ $strings.LabelType }}</th>
<th class="w-16 min-w-16 text-center">{{ $strings.HeaderEpisodes }}</th>
<th class="w-16 min-w-16 text-center hidden lg:table-cell">{{ $strings.LabelRSSFeedPreventIndexing }}</th>
<th class="w-48 min-w-24 flex-grow hidden md:table-cell">{{ $strings.LabelLastUpdate }}</th>
<th class="w-16 text-left"></th>
</tr>
<tr v-for="feed in feedsList" :key="feed.id" class="cursor-pointer h-12" @click="showFeed(feed)">
<!-- -->
<td>
<img :src="coverUrl(feed)" class="h-full w-full" />
</td>
<!-- -->
<td class="w-48 max-w-64 min-w-24 text-left truncate">
<p class="truncate">{{ feed.meta.title }}</p>
</td>
<!-- -->
<td class="hidden xl:table-cell">
<p class="truncate">{{ feed.slug }}</p>
</td>
<!-- -->
<td class="hidden md:table-cell">
<p class="">{{ getEntityType(feed.entityType) }}</p>
</td>
<!-- -->
<td class="text-center">
<p class="">{{ feed.episodes.length }}</p>
</td>
<!-- -->
<td class="text-center leading-none hidden lg:table-cell">
<p v-if="feed.meta.preventIndexing" class="">
<span class="material-symbols text-2xl">check</span>
</p>
</td>
<!-- -->
<td class="text-center hidden md:table-cell">
<ui-tooltip v-if="feed.updatedAt" direction="top" :text="$formatDatetime(feed.updatedAt, dateFormat, timeFormat)">
<p class="text-gray-200">{{ $dateDistanceFromNow(feed.updatedAt) }}</p>
</ui-tooltip>
</td>
<!-- -->
<td class="text-center">
<ui-icon-btn icon="delete" class="mx-0.5" :size="7" bg-color="error" outlined @click.stop="deleteFeedClick(feed)" />
</td>
</tr>
</table>
</div>
</template>
<template v-else>
<div v-if="feedSubscriptions.length" class="block max-w-full">
<div class="flex -mx-1 items-center mb-3">
<div class="w-3/4 px-1">
<form @submit.prevent="feedSubscriptionsSubmit" class="flex flex-grow">
<ui-text-input v-model="feedSubscriptionsSearch" @input="feedSubscriptionsInputUpdate" type="search" :placeholder="$strings.PlaceholderSearchTitle" class="flex-grow text-sm md:text-base" />
</form>
</div>
<div class="flex-grow px-1">
<ui-checkbox v-model="feedSubscriptionsShowOnlyUnhealthy" :label="$strings.LabelFeedShowOnlyUnhealthy" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
<table class="rssFeedsTable text-xs">
<tr class="bg-primary bg-opacity-40 h-12">
<th class="w-16 min-w-16"></th>
<th class="w-48 max-w-64 min-w-24 text-left truncate">{{ $strings.LabelTitle }}/{{ $strings.LabelFeedURL }}</th>
<th class="w-24 min-w-16 text-left">{{ $strings.LabelFeedLastChecked }}</th>
<th class="w-24 min-w-16 text-left">{{ $strings.LabelFeedLastSuccessfulCheck }}</th>
<th class="w-16 min-w-16 text-left">{{ $strings.LabelFeedHealthy }}</th>
<th class="w-16 min-w-16 text-left">{{ $strings.LabelAutoDownloadEpisodes}}</th>
<th class="w-24 min-w-16 text-left">{{ $strings.LabelFeedNextAutomaticCheck }}</th>
<th class="w-16 text-center"></th>
</tr>
<tr v-for="feedSubscription in feedSubscriptionsList" :key="feedSubscription.id" class="cursor-pointer h-12">
<!-- -->
<td>
<covers-preview-cover v-if="feedSubscription?.metadata?.imageUrl" :width="50"
:src="feedSubscription.metadata.imageUrl"
:book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false"/>
<img v-else :src="noCoverUrl" class="h-full w-full"/>
</td>
<!-- -->
<td class="w-48 max-w-64 min-w-24 text-left truncate">
<p class="truncate">{{ feedSubscription.metadata.title }}</p>
<p class="truncate text-xs text-gray-300">{{ feedSubscription.metadata.feedUrl }}</p>
</td>
<!-- -->
<td class="text-left">
<ui-tooltip v-if="feedSubscription.lastEpisodeCheck" direction="top"
:text="$formatDatetime(feedSubscription.lastEpisodeCheck, dateFormat, timeFormat)">
<p class="text-gray-200">{{ $dateDistanceFromNow(feedSubscription.lastEpisodeCheck) }}</p>
</ui-tooltip>
</td>
<!-- -->
<td class="text-left">
<ui-tooltip v-if="feedSubscription.metadata.lastSuccessfulFetchAt" direction="top"
:text="$formatDatetime(feedSubscription.metadata.lastSuccessfulFetchAt, dateFormat, timeFormat)">
<p class="text-gray-200">{{
$dateDistanceFromNow(feedSubscription.metadata.lastSuccessfulFetchAt)
}}</p>
</ui-tooltip>
<p class="text-gray-200" v-else>{{ $strings.MessageNoAvailable }}</p>
</td>
<!-- -->
<td class="text-center leading-none lg:table-cell">
<widgets-feed-healthy-indicator :value="!!feedSubscription.metadata.feedHealthy" />
</td>
<!-- -->
<td class="text-center leading-none lg:table-cell">
<ui-tooltip v-if="feedSubscription.autoDownloadEpisodes" direction="top"
:text="$strings.LabelEnabled">
<span class="material-icons text-2xl">check</span>
</ui-tooltip>
<ui-tooltip v-else direction="top"
:text="$strings.LabelDisabled">
<span class="material-icons text-2xl">close</span>
</ui-tooltip>
</td>
<!-- -->
<td class="text-left">
<ui-tooltip v-if="feedSubscription.autoDownloadEpisodes" direction="top"
:text="`${$strings.LabelCronExpression}: ${feedSubscription.autoDownloadSchedule}`">
<p class="text-gray-200">
{{ nextRun(feedSubscription.autoDownloadSchedule) }}
</p>
</ui-tooltip>
</td>
<!-- -->
<td>
<div class="w-full flex flex-row items-center justify-center">
<ui-tooltip direction="top"
:text="$strings.ButtonCopyFeedURL">
<button class="inline-flex material-icons text-xl mx-1 mt-1 text-white/70 hover:text-white/100"
@click.stop="copyToClipboard(feedSubscription.metadata.feedUrl)">content_copy
</button>
</ui-tooltip>
<ui-tooltip direction="top" :text="$strings.ButtonForceReCheckFeed">
<button class="inline-flex material-icons text-xl mx-1 mt-1 text-white/70 hover:text-white/100"
@click.stop="forceRecheckFeed(feedSubscription)"
:disabled="feedSubscription.isLoading">
<span v-if="feedSubscription.isLoading" class="material-icons">hourglass_empty</span>
<span v-else class="material-icons">autorenew</span>
</button>
</ui-tooltip>
</div>
</td>
</tr>
</table>
</div>
</template>
</div>
</div>
</app-settings-content>
<modals-rssfeed-view-feed-modal v-model="showFeedModal" :feed="selectedFeed" />
<modals-rssfeed-view-feed-modal v-model="showFeedModal" :feed="selectedFeed"/>
</div>
</template>
@ -71,9 +197,18 @@
export default {
data() {
return {
showOpenedFeedsView: true,
showFeedModal: false,
selectedFeed: null,
feeds: []
feeds: [],
feedSubscriptions: [],
feedsSearch: null,
feedsSearchTimeout: null,
feedsSearchText: null,
feedSubscriptionsSearch: null,
feedSubscriptionsSearchTimeout: null,
feedSubscriptionsSearchText: null,
feedSubscriptionsShowOnlyUnhealthy: false,
}
},
computed: {
@ -82,13 +217,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)
})
},
feedSubscriptionsSorted() {
return this.feedSubscriptionsShowOnlyUnhealthy
? this.feedSubscriptions.filter(feedSubscription => !feedSubscription.metadata.feedHealthy)
: this.feedSubscriptions;
},
feedSubscriptionsList() {
return this.feedSubscriptionsSorted.filter((feedSubscription) => {
if (!this.feedSubscriptionsSearchText) return true
if (this.feedSubscriptionsShowOnlyUnhealthy && feedSubscription?.metadata?.feedHealthy) return false
return feedSubscription?.metadata?.title?.toLowerCase().includes(this.feedSubscriptionsSearchText) ||
feedSubscription?.metadata?.feedUrl?.toLowerCase().includes(this.feedSubscriptionsSearchText)
})
},
},
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)
},
feedSubscriptionsSubmit() {},
feedSubscriptionsInputUpdate() {
clearTimeout(this.feedSubscriptionsSearchTimeout)
this.feedSubscriptionsSearchTimeout = setTimeout(() => {
if (!this.feedSubscriptionsSearch || !this.feedSubscriptionsSearch.trim()) {
this.feedSubscriptionsSearchText = ''
return
}
this.feedSubscriptionsSearchText = this.feedSubscriptionsSearch.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,
@ -104,19 +289,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
@ -125,9 +310,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)
@ -139,8 +355,21 @@ export default {
}
this.feeds = data.feeds
},
async loadPodcastsWithExternalFeedSubscriptions() {
const data = await this.$axios.$get(`/api/podcasts/external-podcast-feeds-status`).catch((err) => {
console.error('Failed to load podcasts with external feed subscriptions', err)
return null
})
if (!data) {
this.$toast.error('Failed to load podcasts with external feed subscriptions')
return
}
this.feedSubscriptions = data.podcasts.map(podcast => ({...podcast, isLoading: false}));
},
init() {
this.loadFeeds()
this.loadPodcastsWithExternalFeedSubscriptions()
}
},
mounted() {

View File

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

View File

@ -22,6 +22,7 @@
"ButtonCloseSession": "Close Open Session",
"ButtonCollections": "Collections",
"ButtonConfigureScanner": "Configure Scanner",
"ButtonCopyFeedURL": "Copy Feed URL",
"ButtonCreate": "Create",
"ButtonCreateBackup": "Create Backup",
"ButtonDelete": "Delete",
@ -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",
@ -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",
@ -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",
@ -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.",
@ -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",
@ -779,6 +792,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",
@ -898,6 +912,7 @@
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..",
"PlaceholderSearchEpisode": "Search episode..",
"PlaceholderSearchTitle": "Search title..",
"StatsAuthorsAdded": "authors added",
"StatsBooksAdded": "books added",
"StatsBooksAdditional": "Some additions include…",
@ -916,6 +931,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",

View File

@ -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
*

View File

@ -267,7 +267,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) {
@ -282,13 +282,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()
@ -305,7 +310,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
}
@ -322,12 +327,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()
@ -338,6 +349,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

View File

@ -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) {
@ -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,
@ -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 || [],
@ -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'

View File

@ -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
}
}
@ -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) {

View File

@ -243,6 +243,9 @@ class ApiRouter {
//
this.router.post('/podcasts', PodcastController.create.bind(this))
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
this.router.get('/podcasts/external-podcast-feeds-status', PodcastController.getPodcastsWithExternalFeedsSubscriptions.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/parse', PodcastController.getFeedsFromOPMLText.bind(this))
this.router.post('/podcasts/opml/create', PodcastController.bulkCreatePodcastsFromOpmlFeedUrls.bind(this))
this.router.get('/podcasts/:id/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this))

View File

@ -187,7 +187,9 @@ function migratePodcast(oldLibraryItem, LibraryItem) {
updatedAt: LibraryItem.updatedAt,
coverPath: oldPodcast.coverPath,
tags: oldPodcast.tags,
genres: oldPodcastMetadata.genres
genres: oldPodcastMetadata.genres,
lastSuccessfulFetchAt: oldPodcastMetadata.lastSuccessfulFetchAt || null,
feedHealthy: !!oldPodcastMetadata.feedHealthy || null
}
_newRecords.podcast = Podcast
oldDbIdMap.podcasts[oldLibraryItem.id] = Podcast.id