const fs = require('fs-extra') const cron = require('node-cron') const axios = require('axios') const { parsePodcastRssFeedXml } = require('../utils/podcastUtils') const Logger = require('../Logger') const { downloadFile } = require('../utils/fileUtils') const opmlParser = require('../utils/parsers/parseOPML') const prober = require('../utils/prober') const LibraryFile = require('../objects/files/LibraryFile') const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload') const PodcastEpisode = require('../objects/entities/PodcastEpisode') const AudioFile = require('../objects/files/AudioFile') class PodcastManager { constructor(db, watcher, emitter) { this.db = db this.watcher = watcher this.emitter = emitter this.downloadQueue = [] this.currentDownload = null this.episodeScheduleTask = null this.failedCheckMap = {} } 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) } clearDownloadQueue(libraryItemId = null) { if (!this.downloadQueue.length) return if (!libraryItemId) { Logger.info(`[PodcastManager] Clearing all downloads in queue (${this.downloadQueue.length})`) this.downloadQueue = [] } else { var itemDownloads = this.getEpisodeDownloadsInQueue(libraryItemId) Logger.info(`[PodcastManager] Clearing downloads in queue for item "${libraryItemId}" (${itemDownloads.length})`) this.downloadQueue = this.downloadQueue.filter(d => d.libraryItemId !== libraryItemId) } } async downloadPodcastEpisodes(libraryItem, episodesToDownload) { var index = libraryItem.media.episodes.length + 1 episodesToDownload.forEach((ep) => { var newPe = new PodcastEpisode() newPe.setData(ep, index++) newPe.libraryItemId = libraryItem.id var newPeDl = new PodcastEpisodeDownload() newPeDl.setData(newPe, libraryItem) this.startPodcastEpisodeDownload(newPeDl) }) } async startPodcastEpisodeDownload(podcastEpisodeDownload) { if (this.currentDownload) { this.downloadQueue.push(podcastEpisodeDownload) this.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient()) return } this.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient()) this.currentDownload = podcastEpisodeDownload // Ignores all added files to this dir this.watcher.addIgnoreDir(this.currentDownload.libraryItem.path) var success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => { Logger.error(`[PodcastManager] Podcast Episode download failed`, error) return false }) if (success) { success = await this.scanAddPodcastEpisodeAudioFile() if (!success) { await fs.remove(this.currentDownload.targetPath) this.currentDownload.setFinished(false) } else { Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`) this.currentDownload.setFinished(true) } } else { this.currentDownload.setFinished(false) } this.emitter('episode_download_finished', this.currentDownload.toJSONForClient()) this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) this.currentDownload = null if (this.downloadQueue.length) { this.startPodcastEpisodeDownload(this.downloadQueue.shift()) } } async scanAddPodcastEpisodeAudioFile() { var libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath) // TODO: Set meta tags on new audio file var audioFile = await this.probeAudioFile(libraryFile) if (!audioFile) { return false } var libraryItem = this.db.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id) if (!libraryItem) { Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`) return false } var podcastEpisode = this.currentDownload.podcastEpisode podcastEpisode.audioFile = audioFile libraryItem.media.addPodcastEpisode(podcastEpisode) if (libraryItem.isInvalid) { // First episode added to an empty podcast libraryItem.isInvalid = false } libraryItem.libraryFiles.push(libraryFile) libraryItem.updatedAt = Date.now() await this.db.updateLibraryItem(libraryItem) this.emitter('item_updated', libraryItem.toJSONExpanded()) return true } async getLibraryFile(path, relPath) { var newLibFile = new LibraryFile() await newLibFile.setDataFromPath(path, relPath) return newLibFile } async probeAudioFile(libraryFile) { var path = libraryFile.metadata.path var mediaProbeData = await prober.probe(path) if (mediaProbeData.error) { Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error) return false } var newAudioFile = new AudioFile() newAudioFile.setDataFromProbe(libraryFile, mediaProbeData) 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) } } cancelCron() { Logger.debug(`[PodcastManager] Canceled new podcast episode check cron`) if (this.episodeScheduleTask) { this.episodeScheduleTask.destroy() this.episodeScheduleTask = null } } 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`) 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] > 2) { Logger.error(`[PodcastManager] checkForNewEpisodes 3 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) { 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) } 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()) } } async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter) { if (!podcastLibraryItem.media.metadata.feedUrl) { Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`) return false } var feed = await this.getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl) if (!feed || !feed.episodes) { Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed) return false } // Filter new and not already has var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > dateToCheckForEpisodesAfter && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url)) // Max new episodes for safety = 3 newEpisodes = newEpisodes.slice(0, 3) return newEpisodes } async checkAndDownloadNewEpisodes(libraryItem) { 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) if (newEpisodes.length) { Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`) this.downloadPodcastEpisodes(libraryItem, newEpisodes) } else { Logger.info(`[PodcastManager] No new episodes found for podcast "${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 newEpisodes } getPodcastFeed(feedUrl, excludeEpisodeMetadata = false) { Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}"`) return axios.get(feedUrl, { timeout: 5000 }).then(async (data) => { if (!data || !data.data) { Logger.error('Invalid podcast feed request response') return false } Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}" success - parsing xml`) var payload = await parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata) if (!payload) { return false } return payload.podcast }).catch((error) => { console.error('Failed', error) return false }) } async getOPMLFeeds(opmlText) { var extractedFeeds = opmlParser.parse(opmlText) if (!extractedFeeds || !extractedFeeds.length) { Logger.error('[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML') return { error: 'No RSS feeds found in OPML' } } var rssFeedData = [] for (let feed of extractedFeeds) { var feedData = await this.getPodcastFeed(feed.feedUrl, true) if (feedData) { feedData.metadata.feedUrl = feed.feedUrl rssFeedData.push(feedData) } } return { feeds: rssFeedData } } } module.exports = PodcastManager