Add:Schedule podcast new episode checks

This commit is contained in:
advplyr 2022-08-19 18:41:58 -05:00
parent a690dfe671
commit 46668854ad
7 changed files with 315 additions and 85 deletions

View file

@ -75,7 +75,7 @@ class Server {
this.rssFeedManager = new RssFeedManager(this.db, this.emitter.bind(this))
this.scanner = new Scanner(this.db, this.coverManager, this.emitter.bind(this))
this.cronManager = new CronManager(this.db, this.scanner)
this.cronManager = new CronManager(this.db, this.scanner, this.podcastManager)
// Routers
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.abMergeManager, this.coverManager, this.backupManager, this.watcher, this.cacheManager, this.podcastManager, this.audioMetadataManager, this.rssFeedManager, this.cronManager, this.emitter.bind(this), this.clientEmitter.bind(this))
@ -152,7 +152,6 @@ class Server {
await this.backupManager.init()
await this.logManager.init()
await this.rssFeedManager.init()
this.podcastManager.init()
this.cronManager.init()
if (this.db.serverSettings.scannerDisableWatcher) {

View file

@ -79,12 +79,27 @@ class LibraryItemController {
await this.cacheManager.purgeCoverCache(libraryItem.id)
}
// Book specific
if (libraryItem.isBook) {
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload)
}
// Podcast specific
var isPodcastAutoDownloadUpdated = false
if (libraryItem.isPodcast) {
if (mediaPayload.autoDownloadEpisodes !== undefined && libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
isPodcastAutoDownloadUpdated = true
} else if (mediaPayload.autoDownloadSchedule !== undefined && libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
isPodcastAutoDownloadUpdated = true
}
}
var hasUpdates = libraryItem.media.update(mediaPayload)
if (hasUpdates) {
if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(libraryItem)
}
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())

View file

@ -2,15 +2,20 @@ const cron = require('../libs/nodeCron')
const Logger = require('../Logger')
class CronManager {
constructor(db, scanner) {
constructor(db, scanner, podcastManager) {
this.db = db
this.scanner = scanner
this.podcastManager = podcastManager
this.libraryScanCrons = []
this.podcastCrons = []
this.podcastCronExpressionsExecuting = []
}
init() {
this.initLibraryScanCrons()
this.initPodcastCrons()
}
initLibraryScanCrons() {
@ -57,5 +62,116 @@ class CronManager {
}
}
initPodcastCrons() {
const cronExpressionMap = {}
this.db.libraryItems.forEach((li) => {
if (li.mediaType === 'podcast' && li.media.autoDownloadEpisodes) {
if (!li.media.autoDownloadSchedule) {
Logger.error(`[CronManager] Podcast auto download schedule is not set for ${li.media.metadata.title}`)
} else {
if (!cronExpressionMap[li.media.autoDownloadSchedule]) {
cronExpressionMap[li.media.autoDownloadSchedule] = {
expression: li.media.autoDownloadSchedule,
libraryItemIds: []
}
}
cronExpressionMap[li.media.autoDownloadSchedule].libraryItemIds.push(li.id)
}
}
})
if (!Object.keys(cronExpressionMap).length) return
Logger.debug(`[CronManager] Found ${Object.keys(cronExpressionMap).length} podcast episode schedules to start`)
for (const expression in cronExpressionMap) {
this.startPodcastCron(expression, cronExpressionMap[expression].libraryItemIds)
}
}
startPodcastCron(expression, libraryItemIds) {
try {
Logger.debug(`[CronManager] Scheduling podcast episode check cron "${expression}" for ${libraryItemIds.length} item(s)`)
const task = cron.schedule(expression, () => {
if (this.podcastCronExpressionsExecuting.includes(expression)) {
Logger.warn(`[CronManager] Podcast cron "${expression}" is already executing`)
} else {
this.executePodcastCron(expression, libraryItemIds)
}
})
this.podcastCrons.push({
libraryItemIds,
expression,
task
})
} catch (error) {
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
}
}
async executePodcastCron(expression, libraryItemIds) {
Logger.debug(`[CronManager] Start executing podcast cron ${expression} for ${libraryItemIds.length} item(s)`)
const podcastCron = this.podcastCrons.find(cron => cron.expression === expression)
if (!podcastCron) {
Logger.error(`[CronManager] Podcast cron not found for expression ${expression}`)
return
}
this.podcastCronExpressionsExecuting.push(expression)
// Get podcast library items to check
const libraryItems = []
for (const libraryItemId of libraryItemIds) {
const libraryItem = this.db.libraryItems.find(li => li.id === libraryItemId)
if (!libraryItem) {
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out
} else {
libraryItems.push(libraryItem)
}
}
// Run episode checks
for (const libraryItem of libraryItems) {
const keepAutoDownloading = await this.podcastManager.runEpisodeCheck(libraryItem)
if (!keepAutoDownloading) { // auto download was disabled
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter(lid => lid !== libraryItemId) // Filter it out
}
}
// Stop and remove cron if no more library items
if (!podcastCron.libraryItemIds.length) {
this.removePodcastEpisodeCron(podcastCron)
return
}
Logger.debug(`[CronManager] Finished executing podcast cron ${expression} for ${libraryItems.length} item(s)`)
this.podcastCronExpressionsExecuting = this.podcastCronExpressionsExecuting.filter(exp => exp !== expression)
}
removePodcastEpisodeCron(podcastCron) {
Logger.info(`[CronManager] Stopping & removing podcast episode cron for ${podcastCron.expression}`)
if (podcastCron.task) podcastCron.task.stop()
this.podcastCrons = this.podcastCrons.filter(pc => pc.expression !== podcastCron.expression)
}
checkUpdatePodcastCron(libraryItem) {
// Remove from old cron by library item id
const existingCron = this.podcastCrons.find(pc => pc.libraryItemIds.includes(libraryItem.id))
if (existingCron) {
existingCron.libraryItemIds = existingCron.libraryItemIds.filter(lid => lid !== libraryItem.id)
if (!existingCron.libraryItemIds.length) {
this.removePodcastEpisodeCron(existingCron)
}
}
// Add to cron or start new cron
if (libraryItem.media.autoDownloadEpisodes && libraryItem.media.autoDownloadSchedule) {
const cronMatchingExpression = this.podcastCrons.find(pc => pc.expression === libraryItem.media.autoDownloadSchedule)
if (cronMatchingExpression) {
cronMatchingExpression.libraryItemIds.push(libraryItem.id)
Logger.info(`[CronManager] Added podcast "${libraryItem.media.metadata.title}" to auto dl episode cron "${cronMatchingExpression.expression}"`)
} else {
this.startPodcastCron(libraryItem.media.autoDownloadSchedule, [libraryItem.id])
}
}
}
}
module.exports = CronManager

View file

@ -1,5 +1,4 @@
const fs = require('../libs/fsExtra')
const cron = require('../libs/nodeCron')
const axios = require('axios')
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
@ -23,22 +22,14 @@ class PodcastManager {
this.downloadQueue = []
this.currentDownload = null
this.episodeScheduleTask = null
this.failedCheckMap = {},
this.MaxFailedEpisodeChecks = 24
this.failedCheckMap = {}
this.MaxFailedEpisodeChecks = 24
}
get serverSettings() {
return this.db.serverSettings || {}
}
init() {
var podcastsWithAutoDownload = this.db.libraryItems.some(li => li.mediaType === 'podcast' && li.media.autoDownloadEpisodes)
if (podcastsWithAutoDownload) {
this.schedulePodcastEpisodeCron()
}
}
getEpisodeDownloadsInQueue(libraryItemId) {
return this.downloadQueue.filter(d => d.libraryItemId === libraryItemId)
}
@ -189,73 +180,45 @@ class PodcastManager {
return newAudioFile
}
schedulePodcastEpisodeCron() {
try {
Logger.debug(`[PodcastManager] Scheduled podcast episode check cron "${this.serverSettings.podcastEpisodeSchedule}"`)
this.episodeScheduleTask = cron.schedule(this.serverSettings.podcastEpisodeSchedule, () => {
Logger.debug(`[PodcastManager] Running cron`)
this.checkForNewEpisodes()
})
} catch (error) {
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
}
}
// Returns false if auto download episodes was disabled (disabled if reaches max failed checks)
async runEpisodeCheck(libraryItem) {
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
cancelCron() {
Logger.debug(`[PodcastManager] Canceled new podcast episode check cron`)
if (this.episodeScheduleTask) {
this.episodeScheduleTask.destroy()
this.episodeScheduleTask = null
}
}
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
// lastEpisodeCheckDate will be the current time when adding a new podcast
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
async checkForNewEpisodes() {
var podcastsWithAutoDownload = this.db.libraryItems.filter(li => li.mediaType === 'podcast' && li.media.autoDownloadEpisodes)
if (!podcastsWithAutoDownload.length) {
Logger.info(`[PodcastManager] checkForNewEpisodes - No podcasts with auto download set`)
this.cancelCron()
return
}
Logger.debug(`[PodcastManager] checkForNewEpisodes - Checking ${podcastsWithAutoDownload.length} Podcasts`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter)
Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes ? newEpisodes.length : 'N/A'} episodes found`)
for (const libraryItem of podcastsWithAutoDownload) {
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
Logger.info(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
// lastEpisodeCheckDate will be the current time when adding a new podcast
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
Logger.debug(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter)
Logger.debug(`[PodcastManager] checkForNewEpisodes checked result ${newEpisodes ? newEpisodes.length : 'N/A'}`)
if (!newEpisodes) { // Failed
// Allow up to 3 failed attempts before disabling auto download
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
this.failedCheckMap[libraryItem.id]++
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
Logger.error(`[PodcastManager] checkForNewEpisodes ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`)
libraryItem.media.autoDownloadEpisodes = false
delete this.failedCheckMap[libraryItem.id]
} else {
Logger.warn(`[PodcastManager] checkForNewEpisodes ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
}
} else if (newEpisodes.length) {
if (!newEpisodes) { // Failed
// Allow up to MaxFailedEpisodeChecks failed attempts before disabling auto download
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
this.failedCheckMap[libraryItem.id]++
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`)
libraryItem.media.autoDownloadEpisodes = false
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)
} else {
delete this.failedCheckMap[libraryItem.id]
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
}
libraryItem.media.lastEpisodeCheck = Date.now()
libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
} 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)
} else {
delete this.failedCheckMap[libraryItem.id]
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
}
libraryItem.media.lastEpisodeCheck = Date.now()
libraryItem.updatedAt = Date.now()
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
return libraryItem.media.autoDownloadEpisodes
}
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter) {

View file

@ -18,6 +18,7 @@ class Podcast {
this.episodes = []
this.autoDownloadEpisodes = false
this.autoDownloadSchedule = null
this.lastEpisodeCheck = 0
this.maxEpisodesToKeep = 0
@ -40,6 +41,7 @@ class Podcast {
return podcastEpisode
})
this.autoDownloadEpisodes = !!podcast.autoDownloadEpisodes
this.autoDownloadSchedule = podcast.autoDownloadSchedule || '0 * * * *' // Added in 2.1.3 so default to hourly
this.lastEpisodeCheck = podcast.lastEpisodeCheck || 0
this.maxEpisodesToKeep = podcast.maxEpisodesToKeep || 0
}
@ -52,6 +54,7 @@ class Podcast {
tags: [...this.tags],
episodes: this.episodes.map(e => e.toJSON()),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck,
maxEpisodesToKeep: this.maxEpisodesToKeep
}
@ -64,6 +67,7 @@ class Podcast {
tags: [...this.tags],
numEpisodes: this.episodes.length,
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck,
maxEpisodesToKeep: this.maxEpisodesToKeep,
size: this.size
@ -78,6 +82,7 @@ class Podcast {
tags: [...this.tags],
episodes: this.episodes.map(e => e.toJSONExpanded()),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck,
maxEpisodesToKeep: this.maxEpisodesToKeep,
size: this.size
@ -165,14 +170,15 @@ class Podcast {
return null
}
setData(mediaMetadata) {
setData(mediaData) {
this.metadata = new PodcastMetadata()
if (mediaMetadata.metadata) {
this.metadata.setData(mediaMetadata.metadata)
if (mediaData.metadata) {
this.metadata.setData(mediaData.metadata)
}
this.coverPath = mediaMetadata.coverPath || null
this.autoDownloadEpisodes = !!mediaMetadata.autoDownloadEpisodes
this.coverPath = mediaData.coverPath || null
this.autoDownloadEpisodes = !!mediaData.autoDownloadEpisodes
this.autoDownloadSchedule = mediaData.autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule
this.lastEpisodeCheck = Date.now() // Makes sure new episodes are after this
}