const Path = require('path') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') const Watcher = require('../Watcher') const fs = require('../libs/fsExtra') const { getPodcastFeed } = require('../utils/podcastUtils') const { removeFile, downloadFile, sanitizeFilename, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') const { levenshteinDistance } = require('../utils/index') const opmlParser = require('../utils/parsers/parseOPML') const opmlGenerator = require('../utils/generators/opmlGenerator') const prober = require('../utils/prober') const ffmpegHelpers = require('../utils/ffmpegHelpers') const TaskManager = require('./TaskManager') const CoverManager = require('../managers/CoverManager') const NotificationManager = require('../managers/NotificationManager') const LibraryFile = require('../objects/files/LibraryFile') const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload') const AudioFile = require('../objects/files/AudioFile') class PodcastManager { constructor() { /** @type {PodcastEpisodeDownload[]} */ this.downloadQueue = [] /** @type {PodcastEpisodeDownload} */ this.currentDownload = null this.failedCheckMap = {} this.MaxFailedEpisodeChecks = 24 } 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) SocketAuthority.emitter('episode_download_queue_cleared', libraryItemId) } } /** * * @param {import('../models/LibraryItem')} libraryItem * @param {import('../utils/podcastUtils').RssPodcastEpisode[]} episodesToDownload * @param {boolean} isAutoDownload - If this download was triggered by auto download */ async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) { for (const ep of episodesToDownload) { const newPeDl = new PodcastEpisodeDownload() newPeDl.setData(ep, libraryItem, isAutoDownload, libraryItem.libraryId) this.startPodcastEpisodeDownload(newPeDl) } } /** * * @param {PodcastEpisodeDownload} podcastEpisodeDownload * @returns */ async startPodcastEpisodeDownload(podcastEpisodeDownload) { if (this.currentDownload) { this.downloadQueue.push(podcastEpisodeDownload) SocketAuthority.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient()) return } const taskData = { libraryId: podcastEpisodeDownload.libraryId, libraryItemId: podcastEpisodeDownload.libraryItemId } const taskTitleString = { text: 'Downloading episode', key: 'MessageDownloadingEpisode' } const taskDescriptionString = { text: `Downloading episode "${podcastEpisodeDownload.episodeTitle}".`, key: 'MessageTaskDownloadingEpisodeDescription', subs: [podcastEpisodeDownload.episodeTitle] } const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData) SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient()) this.currentDownload = podcastEpisodeDownload // If this file already exists then append a uuid to the filename // e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3" // this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802) if (await fs.pathExists(this.currentDownload.targetPath)) { this.currentDownload.appendRandomId = true } // Ignores all added files to this dir Watcher.addIgnoreDir(this.currentDownload.libraryItem.path) Watcher.ignoreFilePathsDownloading.add(this.currentDownload.targetPath) // Make sure podcast library item folder exists if (!(await fs.pathExists(this.currentDownload.libraryItem.path))) { Logger.warn(`[PodcastManager] Podcast episode download: Podcast folder no longer exists at "${this.currentDownload.libraryItem.path}" - Creating it`) await fs.mkdir(this.currentDownload.libraryItem.path) } let success = false if (this.currentDownload.isMp3) { // Download episode and tag it success = await ffmpegHelpers.downloadPodcastEpisode(this.currentDownload).catch((error) => { Logger.error(`[PodcastManager] Podcast Episode download failed`, error) return false }) } else { // Download episode only 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) const taskFailedString = { text: 'Failed', key: 'MessageTaskFailed' } task.setFailed(taskFailedString) } else { Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.episodeTitle}"`) this.currentDownload.setFinished(true) task.setFinished() } } else { const taskFailedString = { text: 'Failed', key: 'MessageTaskFailed' } task.setFailed(taskFailedString) this.currentDownload.setFinished(false) } TaskManager.taskFinished(task) SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient()) Watcher.removeIgnoreDir(this.currentDownload.libraryItem.path) Watcher.ignoreFilePathsDownloading.delete(this.currentDownload.targetPath) this.currentDownload = null if (this.downloadQueue.length) { this.startPodcastEpisodeDownload(this.downloadQueue.shift()) } } /** * Scans the downloaded audio file, create the podcast episode, remove oldest episode if necessary * @returns {Promise} - Returns true if added */ async scanAddPodcastEpisodeAudioFile() { const libraryFile = new LibraryFile() await libraryFile.setDataFromPath(this.currentDownload.targetPath, this.currentDownload.targetRelPath) const audioFile = await this.probeAudioFile(libraryFile) if (!audioFile) { return false } const libraryItem = await Database.libraryItemModel.getExpandedById(this.currentDownload.libraryItem.id) if (!libraryItem) { Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`) return false } const podcastEpisode = await Database.podcastEpisodeModel.createFromRssPodcastEpisode(this.currentDownload.rssPodcastEpisode, libraryItem.media.id, audioFile) libraryItem.libraryFiles.push(libraryFile.toJSON()) libraryItem.changed('libraryFiles', true) libraryItem.media.podcastEpisodes.push(podcastEpisode) if (this.currentDownload.isAutoDownload) { // Check setting maxEpisodesToKeep and remove episode if necessary const numEpisodesWithPubDate = libraryItem.media.podcastEpisodes.filter((ep) => !!ep.publishedAt).length if (libraryItem.media.maxEpisodesToKeep && numEpisodesWithPubDate > libraryItem.media.maxEpisodesToKeep) { Logger.info(`[PodcastManager] # of episodes (${numEpisodesWithPubDate}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`) const episodeToRemove = await this.getRemoveOldestEpisode(libraryItem, podcastEpisode.id) if (episodeToRemove) { // Remove episode from playlists await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id]) // Remove media progress for this episode await Database.mediaProgressModel.destroy({ where: { mediaItemId: episodeToRemove.id } }) await episodeToRemove.destroy() libraryItem.media.podcastEpisodes = libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeToRemove.id) // Remove library file libraryItem.libraryFiles = libraryItem.libraryFiles.filter((lf) => lf.ino !== episodeToRemove.audioFile.ino) } } } await libraryItem.save() SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id) podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded() SocketAuthority.emitter('episode_added', podcastEpisodeExpanded) if (this.currentDownload.isAutoDownload) { // Notifications only for auto downloaded episodes NotificationManager.onPodcastEpisodeDownloaded(libraryItem, podcastEpisode) } return true } /** * Find oldest episode publishedAt and delete the audio file * * @param {import('../models/LibraryItem').LibraryItemExpanded} libraryItem * @param {string} episodeIdJustDownloaded * @returns {Promise} - Returns the episode to remove */ async getRemoveOldestEpisode(libraryItem, episodeIdJustDownloaded) { let smallestPublishedAt = 0 /** @type {import('../models/PodcastEpisode')} */ let oldestEpisode = null /** @type {import('../models/PodcastEpisode')[]} */ const podcastEpisodes = libraryItem.media.podcastEpisodes for (const ep of podcastEpisodes) { if (ep.id === episodeIdJustDownloaded || !ep.publishedAt) continue if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) { smallestPublishedAt = ep.publishedAt oldestEpisode = ep } } if (oldestEpisode?.audioFile) { Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`) const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path) if (successfullyDeleted) { return oldestEpisode } else { Logger.warn(`[PodcastManager] Failed to remove oldest episode "${oldestEpisode.title}"`) } } return null } /** * * @param {LibraryFile} libraryFile * @returns {Promise} */ async probeAudioFile(libraryFile) { const path = libraryFile.metadata.path const mediaProbeData = await prober.probe(path) if (mediaProbeData.error) { Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error) return null } const newAudioFile = new AudioFile() newAudioFile.setDataFromProbe(libraryFile, mediaProbeData) newAudioFile.index = 1 return newAudioFile } /** * * @param {import('../models/LibraryItem')} libraryItem * @returns {Promise} - Returns false if auto download episodes was disabled (disabled if reaches max failed checks) */ async runEpisodeCheck(libraryItem) { const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0 const latestEpisodePublishedAt = libraryItem.media.getLatestEpisodePublishedAt() Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" | Last check: ${new Date(lastEpisodeCheck)} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`) // Use latest episode pubDate if exists OR fallback to using lastEpisodeCheck // lastEpisodeCheck will be the current time when adding a new podcast const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheck Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`) const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload) Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`) 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.title}" - disabling auto download`) libraryItem.media.autoDownloadEpisodes = false delete this.failedCheckMap[libraryItem.id] } else { Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}"`) } } else if (newEpisodes.length) { delete this.failedCheckMap[libraryItem.id] Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`) this.downloadPodcastEpisodes(libraryItem, newEpisodes, true) } else { delete this.failedCheckMap[libraryItem.id] Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.title}"`) } libraryItem.media.lastEpisodeCheck = new Date() await libraryItem.media.save() libraryItem.changed('updatedAt', true) await libraryItem.save() SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) return libraryItem.media.autoDownloadEpisodes } /** * * @param {import('../models/LibraryItem')} podcastLibraryItem * @param {number} dateToCheckForEpisodesAfter - Unix timestamp * @param {number} maxNewEpisodes * @returns {Promise} */ async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) { if (!podcastLibraryItem.media.feedURL) { Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`) return null } const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL) if (!feed?.episodes) { Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed) return null } // Filter new and not already has let newEpisodes = feed.episodes.filter((ep) => ep.publishedAt > dateToCheckForEpisodesAfter && !podcastLibraryItem.media.checkHasEpisodeByFeedEpisode(ep)) if (maxNewEpisodes > 0) { newEpisodes = newEpisodes.slice(0, maxNewEpisodes) } return newEpisodes } /** * * @param {import('../models/LibraryItem')} libraryItem * @param {*} maxEpisodesToDownload * @returns {Promise} */ async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) { const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0 const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem.media.lastEpisodeCheck : 'Never' Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.title}" - Last episode check: ${lastEpisodeCheckDate}`) const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, lastEpisodeCheck, maxEpisodesToDownload) if (newEpisodes?.length) { Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`) this.downloadPodcastEpisodes(libraryItem, newEpisodes, false) } else { Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.title}"`) } libraryItem.media.lastEpisodeCheck = new Date() await libraryItem.media.save() libraryItem.changed('updatedAt', true) await libraryItem.save() SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) return newEpisodes || [] } async findEpisode(rssFeedUrl, searchTitle) { const feed = await getPodcastFeed(rssFeedUrl).catch(() => { return null }) if (!feed || !feed.episodes) { return null } const matches = [] feed.episodes.forEach((ep) => { if (!ep.title) return const epTitle = ep.title.toLowerCase().trim() if (epTitle === searchTitle) { matches.push({ episode: ep, levenshtein: 0 }) } else { const levenshtein = levenshteinDistance(searchTitle, epTitle, true) if (levenshtein <= 6 && epTitle.length > levenshtein) { matches.push({ episode: ep, levenshtein }) } } }) return matches.sort((a, b) => a.levenshtein - b.levenshtein) } getParsedOPMLFileFeeds(opmlText) { return opmlParser.parse(opmlText) } async getOPMLFeeds(opmlText) { const extractedFeeds = opmlParser.parse(opmlText) if (!extractedFeeds?.length) { Logger.error('[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML') return { error: 'No RSS feeds found in OPML' } } const rssFeedData = [] for (let feed of extractedFeeds) { const feedData = await getPodcastFeed(feed.feedUrl, true) if (feedData) { feedData.metadata.feedUrl = feed.feedUrl rssFeedData.push(feedData) } } return { feeds: rssFeedData } } /** * OPML file string for podcasts in a library * @param {import('../models/Podcast')[]} podcasts * @returns {string} XML string */ generateOPMLFileText(podcasts) { return opmlGenerator.generate(podcasts) } getDownloadQueueDetails(libraryId = null) { let _currentDownload = this.currentDownload if (libraryId && _currentDownload?.libraryId !== libraryId) _currentDownload = null return { currentDownload: _currentDownload?.toJSONForClient(), queue: this.downloadQueue.filter((item) => !libraryId || item.libraryId === libraryId).map((item) => item.toJSONForClient()) } } /** * * @param {string[]} rssFeedUrls * @param {import('../models/LibraryFolder')} folder * @param {boolean} autoDownloadEpisodes * @param {import('../managers/CronManager')} cronManager */ async createPodcastsFromFeedUrls(rssFeedUrls, folder, autoDownloadEpisodes, cronManager) { const taskTitleString = { text: 'OPML import', key: 'MessageTaskOpmlImport' } const taskDescriptionString = { text: `Creating podcasts from ${rssFeedUrls.length} RSS feeds`, key: 'MessageTaskOpmlImportDescription', subs: [rssFeedUrls.length] } const task = TaskManager.createAndAddTask('opml-import', taskTitleString, taskDescriptionString, true, null) let numPodcastsAdded = 0 Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Importing ${rssFeedUrls.length} RSS feeds to folder "${folder.path}"`) for (const feedUrl of rssFeedUrls) { const feed = await getPodcastFeed(feedUrl).catch(() => null) if (!feed?.episodes) { const taskTitleStringFeed = { text: 'OPML import feed', key: 'MessageTaskOpmlImportFeed' } const taskDescriptionStringFeed = { text: `Importing RSS feed "${feedUrl}"`, key: 'MessageTaskOpmlImportFeedDescription', subs: [feedUrl] } const taskErrorString = { text: 'Failed to get podcast feed', key: 'MessageTaskOpmlImportFeedFailed' } TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringFeed, taskErrorString) Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to get podcast feed for "${feedUrl}"`) continue } const podcastFilename = sanitizeFilename(feed.metadata.title) const podcastPath = filePathToPOSIX(`${folder.path}/${podcastFilename}`) // Check if a library item with this podcast folder exists already const existingLibraryItem = (await Database.libraryItemModel.count({ where: { path: podcastPath } })) > 0 if (existingLibraryItem) { Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Podcast already exists at path "${podcastPath}"`) const taskTitleStringFeed = { text: 'OPML import feed', key: 'MessageTaskOpmlImportFeed' } const taskDescriptionStringPodcast = { text: `Creating podcast "${feed.metadata.title}"`, key: 'MessageTaskOpmlImportFeedPodcastDescription', subs: [feed.metadata.title] } const taskErrorString = { text: 'Podcast already exists at path', key: 'MessageTaskOpmlImportFeedPodcastExists' } TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString) continue } const successCreatingPath = await fs .ensureDir(podcastPath) .then(() => true) .catch((error) => { Logger.error(`[PodcastManager] Failed to ensure podcast dir "${podcastPath}"`, error) return false }) if (!successCreatingPath) { Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast folder at "${podcastPath}"`) const taskTitleStringFeed = { text: 'OPML import feed', key: 'MessageTaskOpmlImportFeed' } const taskDescriptionStringPodcast = { text: `Creating podcast "${feed.metadata.title}"`, key: 'MessageTaskOpmlImportFeedPodcastDescription', subs: [feed.metadata.title] } const taskErrorString = { text: 'Failed to create podcast folder', key: 'MessageTaskOpmlImportFeedPodcastFailed' } TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString) continue } let newLibraryItem = null const transaction = await Database.sequelize.transaction() try { const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath) const podcastPayload = { autoDownloadEpisodes, metadata: { title: feed.metadata.title, author: feed.metadata.author, description: feed.metadata.description, releaseDate: '', genres: [...feed.metadata.categories], feedUrl: feed.metadata.feedUrl, imageUrl: feed.metadata.image, itunesPageUrl: '', itunesId: '', itunesArtistId: '', language: '', numEpisodes: feed.numEpisodes } } const podcast = await Database.podcastModel.createFromRequest(podcastPayload, transaction) newLibraryItem = await Database.libraryItemModel.create( { ino: libraryItemFolderStats.ino, path: podcastPath, relPath: podcastFilename, mediaId: podcast.id, mediaType: 'podcast', isFile: false, isMissing: false, isInvalid: false, mtime: libraryItemFolderStats.mtimeMs || 0, ctime: libraryItemFolderStats.ctimeMs || 0, birthtime: libraryItemFolderStats.birthtimeMs || 0, size: 0, libraryFiles: [], extraData: {}, libraryId: folder.libraryId, libraryFolderId: folder.id }, { transaction } ) await transaction.commit() } catch (error) { await transaction.rollback() Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast library item for "${feed.metadata.title}"`, error) const taskTitleStringFeed = { text: 'OPML import feed', key: 'MessageTaskOpmlImportFeed' } const taskDescriptionStringPodcast = { text: `Creating podcast "${feed.metadata.title}"`, key: 'MessageTaskOpmlImportFeedPodcastDescription', subs: [feed.metadata.title] } const taskErrorString = { text: 'Failed to create podcast library item', key: 'MessageTaskOpmlImportFeedPodcastFailed' } TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString) continue } newLibraryItem.media = await newLibraryItem.getMediaExpanded() // Download and save cover image if (typeof feed.metadata.image === 'string' && feed.metadata.image.startsWith('http')) { // Podcast cover will always go into library item folder const coverResponse = await CoverManager.downloadCoverFromUrlNew(feed.metadata.image, newLibraryItem.id, newLibraryItem.path, true) if (coverResponse.error) { Logger.error(`[PodcastManager] Download cover error from "${feed.metadata.image}": ${coverResponse.error}`) } else if (coverResponse.cover) { const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover) if (!coverImageFileStats) { Logger.error(`[PodcastManager] Failed to get cover image stats for "${coverResponse.cover}"`) } else { // Add libraryFile to libraryItem and coverPath to podcast const newLibraryFile = { ino: coverImageFileStats.ino, fileType: 'image', addedAt: Date.now(), updatedAt: Date.now(), metadata: { filename: Path.basename(coverResponse.cover), ext: Path.extname(coverResponse.cover).slice(1), path: coverResponse.cover, relPath: Path.basename(coverResponse.cover), size: coverImageFileStats.size, mtimeMs: coverImageFileStats.mtimeMs || 0, ctimeMs: coverImageFileStats.ctimeMs || 0, birthtimeMs: coverImageFileStats.birthtimeMs || 0 } } newLibraryItem.libraryFiles.push(newLibraryFile) newLibraryItem.changed('libraryFiles', true) await newLibraryItem.save() newLibraryItem.media.coverPath = coverResponse.cover await newLibraryItem.media.save() } } } SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded()) // Turn on podcast auto download cron if not already on if (newLibraryItem.media.autoDownloadEpisodes) { cronManager.checkUpdatePodcastCron(newLibraryItem) } numPodcastsAdded++ } const taskFinishedString = { text: `Added ${numPodcastsAdded} podcasts`, key: 'MessageTaskOpmlImportFinished', subs: [numPodcastsAdded] } task.setFinished(taskFinishedString) TaskManager.taskFinished(task) Logger.info(`[PodcastManager] createPodcastsFromFeedUrls: Finished OPML import. Created ${numPodcastsAdded} podcasts out of ${rssFeedUrls.length} RSS feed URLs`) } } module.exports = PodcastManager