RSS Manager code

This commit is contained in:
mfcar 2023-10-03 12:32:39 +01:00
parent 2ada293e8a
commit aefcc4490f
No known key found for this signature in database
7 changed files with 508 additions and 233 deletions

View file

@ -10,7 +10,15 @@
</div> </div>
</div> </div>
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" :label="$strings.LabelRSSFeedURL" class="mt-2" /> <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" />
<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" /> <ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" :label="$strings.LabelDescription" class="mt-2" />
@ -71,7 +79,9 @@ export default {
itunesArtistId: null, itunesArtistId: null,
explicit: false, explicit: false,
language: null, language: null,
type: null type: null,
feedHealthy: false,
lastSuccessfulFetchAt: null
}, },
newTags: [] newTags: []
} }
@ -229,6 +239,8 @@ export default {
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.type = this.mediaMetadata.type || 'episodic' 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 || [])] this.newTags = [...(this.media.tags || [])]
}, },

View file

@ -1,61 +1,180 @@
<template> <template>
<div> <div>
<app-settings-content :header-text="$strings.HeaderRSSFeeds"> <app-settings-content :header-text="$strings.HeaderRSSFeeds">
<div v-if="feeds.length" class="block max-w-full"> <div class="w-full py-2">
<table class="rssFeedsTable text-xs"> <div class="flex -mb-px">
<tr class="bg-primary bg-opacity-40 h-12"> <div
<th class="w-16 min-w-16"></th> class="w-1/2 h-8 rounded-tl-md relative border border-black-200 flex items-center justify-center cursor-pointer"
<th class="w-48 max-w-64 min-w-24 text-left truncate">{{ $strings.LabelTitle }}</th> :class="!showIncomingFeedsView ? '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'"
<th class="w-48 min-w-24 text-left hidden xl:table-cell">{{ $strings.LabelSlug }}</th> @click="showIncomingFeedsView = false">
<th class="w-24 min-w-16 text-left hidden md:table-cell">{{ $strings.LabelType }}</th> <p class="text-sm">Opened Feeds</p>
<th class="w-16 min-w-16 text-center">{{ $strings.HeaderEpisodes }}</th> </div>
<th class="w-16 min-w-16 text-center hidden lg:table-cell">{{ $strings.LabelRSSFeedPreventIndexing }}</th> <div
<th class="w-48 min-w-24 flex-grow hidden md:table-cell">{{ $strings.LabelLastUpdate }}</th> class="w-1/2 h-8 rounded-tr-md relative border border-black-200 flex items-center justify-center -ml-px cursor-pointer"
<th class="w-16 text-left"></th> :class="showIncomingFeedsView ? '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'"
</tr> @click="showIncomingFeedsView = true">
<p class="text-sm">Subscribed Feeds</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="showIncomingFeedsView">
<div v-if="incomingFeeds.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="incomingFeedsSubmit" class="flex flex-grow">
<ui-text-input v-model="incomingFeedsSearch" @input="incomingFeedsInputUpdate" 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="incomingFeedShowOnlyUnhealthy" :label="$strings.LabelFeedShowOnlyUnhealthy" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
</div>
<tr v-for="feed in feeds" :key="feed.id" class="cursor-pointer h-12" @click="showFeed(feed)"> <table class="rssFeedsTable text-xs">
<!-- --> <tr class="bg-primary bg-opacity-40 h-12">
<td> <th class="w-16 min-w-16"></th>
<img :src="coverUrl(feed)" class="h-full w-full" /> <th class="w-48 max-w-64 min-w-24 text-left truncate">{{ $strings.LabelTitle }}/{{ $strings.LabelFeedURL }}</th>
</td> <th class="w-24 min-w-16 text-left">{{ $strings.LabelFeedLastChecked }}</th>
<!-- --> <th class="w-24 min-w-16 text-left">{{ $strings.LabelFeedLastSuccessfulCheck }}</th>
<td class="w-48 max-w-64 min-w-24 text-left truncate"> <th class="w-16 min-w-16 text-left">{{ $strings.LabelFeedHealthy }}</th>
<p class="truncate">{{ feed.meta.title }}</p> <th class="w-24 min-w-16 text-left">{{ $strings.LabelFeedNextAutomaticCheck }}</th>
</td> <th class="w-16 text-center"></th>
<!-- --> </tr>
<td class="hidden xl:table-cell">
<p class="truncate">{{ feed.slug }}</p> <tr v-for="incomingFeed in incomingFeedsList" :key="incomingFeed.id" class="cursor-pointer h-12">
</td> <!-- -->
<!-- --> <td>
<td class="hidden md:table-cell"> <covers-preview-cover v-if="incomingFeed.coverPath" :width="50"
<p class="">{{ getEntityType(feed.entityType) }}</p> :src="$store.getters['globals/getLibraryItemCoverSrcById'](incomingFeed.id)"
</td> :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false"/>
<!-- --> <img v-else :src="noCoverUrl" class="h-full w-full"/>
<td class="text-center"> </td>
<p class="">{{ feed.episodes.length }}</p> <!-- -->
</td> <td class="w-48 max-w-64 min-w-24 text-left truncate">
<!-- --> <p class="truncate">{{ incomingFeed.metadata.title }}</p>
<td class="text-center leading-none hidden lg:table-cell"> <p class="truncate text-xs text-gray-300">{{ incomingFeed.metadata.feedUrl }}</p>
<p v-if="feed.meta.preventIndexing" class=""> </td>
<span class="material-icons text-2xl">check</span> <!-- -->
</p> <td class="text-left">
</td> <ui-tooltip v-if="incomingFeed.lastEpisodeCheck" direction="top"
<!-- --> :text="$formatDatetime(incomingFeed.lastEpisodeCheck, dateFormat, timeFormat)">
<td class="text-center hidden md:table-cell"> <p class="text-gray-200">{{ $dateDistanceFromNow(incomingFeed.lastEpisodeCheck) }}</p>
<ui-tooltip v-if="feed.updatedAt" direction="top" :text="$formatDatetime(feed.updatedAt, dateFormat, timeFormat)"> </ui-tooltip>
<p class="text-gray-200">{{ $dateDistanceFromNow(feed.updatedAt) }}</p> </td>
</ui-tooltip> <!-- -->
</td> <td class="text-left">
<!-- --> <ui-tooltip v-if="incomingFeed.metadata.lastSuccessfulFetchAt" direction="top"
<td class="text-center"> :text="$formatDatetime(incomingFeed.metadata.lastSuccessfulFetchAt, dateFormat, timeFormat)">
<ui-icon-btn icon="delete" class="mx-0.5" :size="7" bg-color="error" outlined @click.stop="deleteFeedClick(feed)" /> <p class="text-gray-200">{{
</td> $dateDistanceFromNow(incomingFeed.metadata.lastSuccessfulFetchAt)
</tr> }}</p>
</table> </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="!!incomingFeed.metadata.feedHealthy" />
</td>
<!-- -->
<td class="text-left">
<ui-tooltip v-if="incomingFeed.autoDownloadEpisodes" direction="top"
:text="`${$strings.LabelCronExpression}: ${incomingFeed.autoDownloadSchedule}`">
<p class="text-gray-200">
{{ nextRun(incomingFeed.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(incomingFeed.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(incomingFeed)"
:disabled="incomingFeed.isLoading">
<span v-if="incomingFeed.isLoading" class="material-icons">hourglass_empty</span>
<span v-else class="material-icons">autorenew</span>
</button>
</ui-tooltip>
</div>
</td>
</tr>
</table>
</div>
</template>
<template v-else>
<div v-if="feeds.length" class="block max-w-full">
<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>
<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-icons 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>
</div>
</div> </div>
</app-settings-content> </app-settings-content>
<modals-rssfeed-view-feed-modal v-model="showFeedModal" :feed="selectedFeed" /> <modals-rssfeed-view-feed-modal v-model="showFeedModal" :feed="selectedFeed"/>
</div> </div>
</template> </template>
@ -63,9 +182,18 @@
export default { export default {
data() { data() {
return { return {
showIncomingFeedsView: false,
showFeedModal: false, showFeedModal: false,
selectedFeed: null, selectedFeed: null,
feeds: [] feeds: [],
incomingFeeds: [],
feedsSearch: null,
feedsSearchTimeout: null,
feedsSearchText: null,
incomingFeedsSearch: null,
incomingFeedsSearchTimeout: null,
incomingFeedsSearchText: null,
incomingFeedShowOnlyUnhealthy: false,
} }
}, },
computed: { computed: {
@ -74,13 +202,63 @@ export default {
}, },
timeFormat() { timeFormat() {
return this.$store.state.serverSettings.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: { 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) { showFeed(feed) {
this.selectedFeed = feed this.selectedFeed = feed
this.showFeedModal = true this.showFeedModal = true
}, },
copyToClipboard(str) {
this.$copyToClipboard(str, this)
},
deleteFeedClick(feed) { deleteFeedClick(feed) {
const payload = { const payload = {
message: this.$strings.MessageConfirmCloseFeed, message: this.$strings.MessageConfirmCloseFeed,
@ -96,19 +274,19 @@ export default {
deleteFeed(feed) { deleteFeed(feed) {
this.processing = true this.processing = true
this.$axios this.$axios
.$post(`/api/feeds/${feed.id}/close`) .$post(`/api/feeds/${feed.id}/close`)
.then(() => { .then(() => {
this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess) this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)
this.show = false this.show = false
this.loadFeeds() this.loadFeeds()
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to close RSS feed', error) console.error('Failed to close RSS feed', error)
this.$toast.error(this.$strings.ToastRSSFeedCloseFailed) this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)
}) })
.finally(() => { .finally(() => {
this.processing = false this.processing = false
}) })
}, },
getEntityType(entityType) { getEntityType(entityType) {
if (entityType === 'libraryItem') return this.$strings.LabelItem if (entityType === 'libraryItem') return this.$strings.LabelItem
@ -117,9 +295,40 @@ export default {
return this.$strings.LabelUnknown return this.$strings.LabelUnknown
}, },
coverUrl(feed) { coverUrl(feed) {
if (!feed.coverPath) return `${this.$config.routerBasePath}/Logo.png` if (!feed.coverPath) return this.noCoverUrl
return `${feed.feedUrl}/cover` 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() { async loadFeeds() {
const data = await this.$axios.$get(`/api/feeds`).catch((err) => { const data = await this.$axios.$get(`/api/feeds`).catch((err) => {
console.error('Failed to load RSS feeds', err) console.error('Failed to load RSS feeds', err)
@ -131,8 +340,21 @@ export default {
} }
this.feeds = data.feeds 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() { init() {
this.loadFeeds() this.loadFeeds()
this.loadIncomingFeeds()
} }
}, },
mounted() { mounted() {

View file

@ -17,6 +17,7 @@
"ButtonCloseFeed": "Close Feed", "ButtonCloseFeed": "Close Feed",
"ButtonCollections": "Collections", "ButtonCollections": "Collections",
"ButtonConfigureScanner": "Configure Scanner", "ButtonConfigureScanner": "Configure Scanner",
"ButtonCopyFeedURL": "Copy Feed URL",
"ButtonCreate": "Create", "ButtonCreate": "Create",
"ButtonCreateBackup": "Create Backup", "ButtonCreateBackup": "Create Backup",
"ButtonDelete": "Delete", "ButtonDelete": "Delete",
@ -24,6 +25,7 @@
"ButtonEdit": "Edit", "ButtonEdit": "Edit",
"ButtonEditChapters": "Edit Chapters", "ButtonEditChapters": "Edit Chapters",
"ButtonEditPodcast": "Edit Podcast", "ButtonEditPodcast": "Edit Podcast",
"ButtonForceReCheckFeed": "Force Re-Check Feed",
"ButtonForceReScan": "Force Re-Scan", "ButtonForceReScan": "Force Re-Scan",
"ButtonFullPath": "Full Path", "ButtonFullPath": "Full Path",
"ButtonHide": "Hide", "ButtonHide": "Hide",
@ -246,7 +248,14 @@
"LabelEpisodeType": "Episode Type", "LabelEpisodeType": "Episode Type",
"LabelExample": "Example", "LabelExample": "Example",
"LabelExplicit": "Explicit", "LabelExplicit": "Explicit",
"LabelFeedHealthy": "Feed Healthy",
"LabelFeedURL": "Feed URL", "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", "LabelFile": "File",
"LabelFileBirthtime": "File Birthtime", "LabelFileBirthtime": "File Birthtime",
"LabelFileModified": "File Modified", "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.", "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", "MessageNoAudioTracks": "No audio tracks",
"MessageNoAuthors": "No Authors", "MessageNoAuthors": "No Authors",
"MessageNoAvailable": "N/A",
"MessageNoBackups": "No Backups", "MessageNoBackups": "No Backups",
"MessageNoBookmarks": "No Bookmarks", "MessageNoBookmarks": "No Bookmarks",
"MessageNoChapters": "No Chapters", "MessageNoChapters": "No Chapters",
@ -642,6 +652,7 @@
"PlaceholderNewPlaylist": "New playlist name", "PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..", "PlaceholderSearch": "Search..",
"PlaceholderSearchEpisode": "Search episode..", "PlaceholderSearchEpisode": "Search episode..",
"PlaceholderSearchTitle": "Search title..",
"ToastAccountUpdateFailed": "Failed to update account", "ToastAccountUpdateFailed": "Failed to update account",
"ToastAccountUpdateSuccess": "Account updated", "ToastAccountUpdateSuccess": "Account updated",
"ToastAuthorImageRemoveFailed": "Failed to remove image", "ToastAuthorImageRemoveFailed": "Failed to remove image",

View file

@ -127,14 +127,32 @@ class PodcastController {
const podcast = await getPodcastFeed(libraryItem.media.metadata.feedUrl) const podcast = await getPodcastFeed(libraryItem.media.metadata.feedUrl)
if (!podcast) { 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') 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 }) 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) { async getFeedsFromOPMLText(req, res) {
if (!req.body.opmlText) { if (!req.body.opmlText) {
return res.sendStatus(400) return res.sendStatus(400)

View file

@ -1,170 +1,171 @@
const { DataTypes, Model } = require('sequelize') const {DataTypes, Model} = require('sequelize')
class Podcast extends Model { class Podcast extends Model {
constructor(values, options) { constructor(values, options) {
super(values, options) super(values, options)
/** @type {string} */ /** @type {string} */
this.id this.id
/** @type {string} */ /** @type {string} */
this.title this.title
/** @type {string} */ /** @type {string} */
this.titleIgnorePrefix this.titleIgnorePrefix
/** @type {string} */ /** @type {string} */
this.author this.author
/** @type {string} */ /** @type {string} */
this.releaseDate this.releaseDate
/** @type {string} */ /** @type {string} */
this.feedURL this.feedURL
/** @type {string} */ /** @type {string} */
this.imageURL this.imageURL
/** @type {string} */ /** @type {string} */
this.description this.description
/** @type {string} */ /** @type {string} */
this.itunesPageURL this.itunesPageURL
/** @type {string} */ /** @type {string} */
this.itunesId this.itunesId
/** @type {string} */ /** @type {string} */
this.itunesArtistId this.itunesArtistId
/** @type {string} */ /** @type {string} */
this.language this.language
/** @type {string} */ /** @type {string} */
this.podcastType this.podcastType
/** @type {boolean} */ /** @type {boolean} */
this.explicit this.explicit
/** @type {boolean} */ /** @type {boolean} */
this.autoDownloadEpisodes this.autoDownloadEpisodes
/** @type {string} */ /** @type {string} */
this.autoDownloadSchedule this.autoDownloadSchedule
/** @type {Date} */ /** @type {Date} */
this.lastEpisodeCheck this.lastEpisodeCheck
/** @type {number} */ /** @type {number} */
this.maxEpisodesToKeep this.maxEpisodesToKeep
/** @type {string} */ /** @type {string} */
this.coverPath this.coverPath
/** @type {string[]} */ /** @type {string[]} */
this.tags this.tags
/** @type {string[]} */ /** @type {string[]} */
this.genres this.genres
/** @type {Date} */ /** @type {Date} */
this.createdAt this.createdAt
/** @type {Date} */ /** @type {Date} */
this.updatedAt this.updatedAt
/** @type {Date} */ /** @type {Date} */
this.lastSuccessfulFetchAt this.lastSuccessfulFetchAt
/** @type {boolean} */ /** @type {boolean} */
this.feedHealthy 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
} }
}
static getFromOld(oldPodcast) { static getOldPodcast(libraryItemExpanded) {
const oldPodcastMetadata = oldPodcast.metadata const podcastExpanded = libraryItemExpanded.media
return { const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index)
id: oldPodcast.id, return {
title: oldPodcastMetadata.title, id: podcastExpanded.id,
titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix, libraryItemId: libraryItemExpanded.id,
author: oldPodcastMetadata.author, metadata: {
releaseDate: oldPodcastMetadata.releaseDate, title: podcastExpanded.title,
feedURL: oldPodcastMetadata.feedUrl, author: podcastExpanded.author,
imageURL: oldPodcastMetadata.imageUrl, description: podcastExpanded.description,
description: oldPodcastMetadata.description, releaseDate: podcastExpanded.releaseDate,
itunesPageURL: oldPodcastMetadata.itunesPageUrl, genres: podcastExpanded.genres,
itunesId: oldPodcastMetadata.itunesId, feedUrl: podcastExpanded.feedURL,
itunesArtistId: oldPodcastMetadata.itunesArtistId, imageUrl: podcastExpanded.imageURL,
language: oldPodcastMetadata.language, itunesPageUrl: podcastExpanded.itunesPageURL,
podcastType: oldPodcastMetadata.type, itunesId: podcastExpanded.itunesId,
explicit: !!oldPodcastMetadata.explicit, itunesArtistId: podcastExpanded.itunesArtistId,
autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes, explicit: podcastExpanded.explicit,
autoDownloadSchedule: oldPodcast.autoDownloadSchedule, language: podcastExpanded.language,
lastEpisodeCheck: oldPodcast.lastEpisodeCheck, type: podcastExpanded.podcastType,
maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep, lastSuccessfulFetchAt: podcastExpanded.lastSuccessfulFetchAt?.valueOf() || null,
maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload, feedHealthy: !!podcastExpanded.feedHealthy
coverPath: oldPodcast.coverPath, },
tags: oldPodcast.tags, coverPath: podcastExpanded.coverPath,
genres: oldPodcastMetadata.genres, tags: podcastExpanded.tags,
lastSuccessfulFetchAt: oldPodcastMetadata.lastSuccessfulFetchAt, episodes: podcastEpisodes || [],
feedHealthy: !!oldPodcastMetadata.feedHealthy autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes,
autoDownloadSchedule: podcastExpanded.autoDownloadSchedule,
lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep,
maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload
}
} }
}
static async getAllIncomingFeeds(){ static getFromOld(oldPodcast) {
const podcasts = await this.findAll() const oldPodcastMetadata = oldPodcast.metadata
return podcasts.map(p => this.getFromOld({metadata: p.dataValues})) 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 async getAllIncomingFeeds() {
* Initialize model const podcasts = await this.findAll()
* @param {import('../Database').sequelize} sequelize const podcastsFiltered = podcasts.filter(p => p.dataValues.feedURL !== null);
*/ return podcastsFiltered.map(p => this.getOldPodcast({media: p.dataValues}))
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, * Initialize model
lastEpisodeCheck: DataTypes.DATE, * @param {import('../Database').sequelize} sequelize
maxEpisodesToKeep: DataTypes.INTEGER, */
maxNewEpisodesToDownload: DataTypes.INTEGER, static init(sequelize) {
coverPath: DataTypes.STRING, super.init({
tags: DataTypes.JSON, id: {
genres: DataTypes.JSON, type: DataTypes.UUID,
lastSuccessfulFetchAt: DataTypes.DATE, defaultValue: DataTypes.UUIDV4,
feedHealthy: DataTypes.BOOLEAN primaryKey: true
}, { },
sequelize, title: DataTypes.STRING,
modelName: 'podcast' 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 module.exports = Podcast

View file

@ -16,6 +16,8 @@ class PodcastMetadata {
this.explicit = false this.explicit = false
this.language = null this.language = null
this.type = null this.type = null
this.lastSuccessfulFetchAt = null
this.feedHealthy = null
if (metadata) { if (metadata) {
this.construct(metadata) this.construct(metadata)
@ -36,6 +38,8 @@ class PodcastMetadata {
this.explicit = metadata.explicit this.explicit = metadata.explicit
this.language = metadata.language || null this.language = metadata.language || null
this.type = metadata.type || 'episodic' this.type = metadata.type || 'episodic'
this.lastSuccessfulFetchAt = metadata.lastSuccessfulFetchAt || null
this.feedHealthy = metadata.feedHealthy || null
} }
toJSON() { toJSON() {
@ -52,7 +56,9 @@ class PodcastMetadata {
itunesArtistId: this.itunesArtistId, itunesArtistId: this.itunesArtistId,
explicit: this.explicit, explicit: this.explicit,
language: this.language, language: this.language,
type: this.type type: this.type,
lastSuccessfulFetchAt: this.lastSuccessfulFetchAt,
feedHealthy: this.feedHealthy
} }
} }
@ -71,7 +77,9 @@ class PodcastMetadata {
itunesArtistId: this.itunesArtistId, itunesArtistId: this.itunesArtistId,
explicit: this.explicit, explicit: this.explicit,
language: this.language, 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) { if (mediaMetadata.genres && mediaMetadata.genres.length) {
this.genres = [...mediaMetadata.genres] this.genres = [...mediaMetadata.genres]
} }
this.lastSuccessfulFetchAt = mediaMetadata.lastSuccessfulFetchAt || null
this.feedHealthy = mediaMetadata.feedHealthy || null
} }
update(payload) { update(payload) {

View file

@ -229,6 +229,7 @@ class ApiRouter {
this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this)) this.router.post('/podcasts/feed', PodcastController.getPodcastFeed.bind(this))
this.router.get('/podcasts/incomingFeeds', PodcastController.getPodcastsWithIncomingFeeds.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/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.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/checknew', PodcastController.middleware.bind(this), PodcastController.checkNewEpisodes.bind(this))
this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this)) this.router.get('/podcasts/:id/downloads', PodcastController.middleware.bind(this), PodcastController.getEpisodeDownloads.bind(this))