diff --git a/client/components/modals/item/EditModal.vue b/client/components/modals/item/EditModal.vue index b2ac7f43..eaa2e117 100644 --- a/client/components/modals/item/EditModal.vue +++ b/client/components/modals/item/EditModal.vue @@ -46,12 +46,14 @@ export default { { id: 'chapters', title: 'Chapters', - component: 'modals-item-tabs-chapters' + component: 'modals-item-tabs-chapters', + mediaType: 'book' }, { id: 'episodes', title: 'Episodes', - component: 'modals-item-tabs-episodes' + component: 'modals-item-tabs-episodes', + mediaType: 'podcast' }, { id: 'files', @@ -66,7 +68,16 @@ export default { { id: 'manage', title: 'Manage', - component: 'modals-item-tabs-manage' + component: 'modals-item-tabs-manage', + mediaType: 'book', + admin: true + }, + { + id: 'schedule', + title: 'Schedule', + component: 'modals-item-tabs-schedule', + mediaType: 'podcast', + admin: true } ] } @@ -120,13 +131,17 @@ export default { userCanDownload() { return this.$store.getters['user/getUserCanDownload'] }, + userIsAdminOrUp() { + return this.$store.getters['user/getIsAdminOrUp'] + }, availableTabs() { if (!this.userCanUpdate && !this.userCanDownload) return [] return this.tabs.filter((tab) => { if (tab.experimental && !this.showExperimentalFeatures) return false - if (tab.id === 'manage' && (this.isMissing || this.mediaType !== 'book')) return false - if (this.mediaType == 'podcast' && tab.id == 'chapters') return false - if (this.mediaType == 'book' && tab.id == 'episodes') return false + if (tab.mediaType && this.mediaType !== tab.mediaType) return false + if (tab.admin && !this.userIsAdminOrUp) return false + + if (tab.id === 'manage' && this.isMissing) return false if ((tab.id === 'manage' || tab.id === 'files') && this.userCanDownload) return true if (tab.id !== 'manage' && tab.id !== 'files' && this.userCanUpdate) return true diff --git a/client/components/modals/item/tabs/Schedule.vue b/client/components/modals/item/tabs/Schedule.vue new file mode 100644 index 00000000..e77425e4 --- /dev/null +++ b/client/components/modals/item/tabs/Schedule.vue @@ -0,0 +1,116 @@ + + + + + Schedule Automatic Episode Downloads + + + + + + + + + {{ isUpdated ? 'Save' : 'No update necessary' }} + + + + + + + + \ No newline at end of file diff --git a/server/Server.js b/server/Server.js index ec11c30c..7bf6e643 100644 --- a/server/Server.js +++ b/server/Server.js @@ -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) { diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index a40fba09..9fbd1a96 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -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()) diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index 66166588..0e1718e3 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -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 \ No newline at end of file diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index f0a05d5e..f0ade6cf 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -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) { diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index bf6d0f67..7e5e0f44 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -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 }
Schedule Automatic Episode Downloads