diff --git a/client/components/ui/TextInputWithLabel.vue b/client/components/ui/TextInputWithLabel.vue index c251555d..509274e1 100644 --- a/client/components/ui/TextInputWithLabel.vue +++ b/client/components/ui/TextInputWithLabel.vue @@ -1,8 +1,10 @@ diff --git a/client/components/widgets/PodcastDetailsEdit.vue b/client/components/widgets/PodcastDetailsEdit.vue index 305cd571..6470b603 100644 --- a/client/components/widgets/PodcastDetailsEdit.vue +++ b/client/components/widgets/PodcastDetailsEdit.vue @@ -40,8 +40,20 @@ -
- +
+
+ +
+
+ + +

+ Max episodes to keep + info_outlined +

+
+
+
@@ -72,6 +84,7 @@ export default { language: null }, autoDownloadEpisodes: false, + maxEpisodesToKeep: 0, newTags: [] } }, @@ -199,6 +212,9 @@ export default { if (this.media.autoDownloadEpisodes !== this.autoDownloadEpisodes) { updatePayload.autoDownloadEpisodes = !!this.autoDownloadEpisodes } + if (this.autoDownloadEpisodes && !isNaN(this.maxEpisodesToKeep) && Number(this.maxEpisodesToKeep) != this.media.maxEpisodesToKeep) { + updatePayload.maxEpisodesToKeep = Number(this.maxEpisodesToKeep) + } return { updatePayload, @@ -220,6 +236,7 @@ export default { this.details.explicit = !!this.mediaMetadata.explicit this.autoDownloadEpisodes = !!this.media.autoDownloadEpisodes + this.maxEpisodesToKeep = this.media.maxEpisodesToKeep || 0 this.newTags = [...(this.media.tags || [])] }, submitForm() { diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index b87c0bf0..f0a05d5e 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -5,7 +5,7 @@ const axios = require('axios') const { parsePodcastRssFeedXml } = require('../utils/podcastUtils') const Logger = require('../Logger') -const { downloadFile } = require('../utils/fileUtils') +const { downloadFile, removeFile } = require('../utils/fileUtils') const { levenshteinDistance } = require('../utils/index') const opmlParser = require('../utils/parsers/parseOPML') const prober = require('../utils/prober') @@ -56,14 +56,14 @@ class PodcastManager { } } - async downloadPodcastEpisodes(libraryItem, episodesToDownload) { + async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) { 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) + newPeDl.setData(newPe, libraryItem, isAutoDownload) this.startPodcastEpisodeDownload(newPeDl) }) } @@ -131,12 +131,46 @@ class PodcastManager { libraryItem.isInvalid = false } libraryItem.libraryFiles.push(libraryFile) + + // Check setting maxEpisodesToKeep and remove episode if necessary + if (this.currentDownload.isAutoDownload) { // only applies for auto-downloaded episodes + if (libraryItem.media.maxEpisodesToKeep && libraryItem.media.episodesWithPubDate.length > libraryItem.media.maxEpisodesToKeep) { + Logger.info(`[PodcastManager] # of episodes (${libraryItem.media.episodesWithPubDate.length}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`) + await this.removeOldestEpisode(libraryItem, podcastEpisode.id) + } + } + libraryItem.updatedAt = Date.now() await this.db.updateLibraryItem(libraryItem) this.emitter('item_updated', libraryItem.toJSONExpanded()) return true } + async removeOldestEpisode(libraryItem, episodeIdJustDownloaded) { + var smallestPublishedAt = 0 + var oldestEpisode = null + libraryItem.media.episodesWithPubDate.filter(ep => ep.id !== episodeIdJustDownloaded).forEach((ep) => { + if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) { + smallestPublishedAt = ep.publishedAt + oldestEpisode = ep + } + }) + // TODO: Should we check for open playback sessions for this episode? + // TODO: remove all user progress for this episode + if (oldestEpisode && oldestEpisode.audioFile) { + Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`) + const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path) + if (successfullyDeleted) { + libraryItem.media.removeEpisode(oldestEpisode.id) + libraryItem.removeLibraryFile(oldestEpisode.audioFile.ino) + return true + } else { + Logger.warn(`[PodcastManager] Failed to remove oldest episode "${oldestEpisode.title}"`) + } + } + return false + } + async getLibraryFile(path, relPath) { var newLibFile = new LibraryFile() await newLibFile.setDataFromPath(path, relPath) @@ -211,7 +245,7 @@ class PodcastManager { } 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) + this.downloadPodcastEpisodes(libraryItem, newEpisodes, true) } else { delete this.failedCheckMap[libraryItem.id] Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`) @@ -248,7 +282,7 @@ class PodcastManager { 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) + this.downloadPodcastEpisodes(libraryItem, newEpisodes, false) } else { Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.metadata.title}"`) } diff --git a/server/objects/PodcastEpisodeDownload.js b/server/objects/PodcastEpisodeDownload.js index 09b9d71d..5a005869 100644 --- a/server/objects/PodcastEpisodeDownload.js +++ b/server/objects/PodcastEpisodeDownload.js @@ -9,6 +9,7 @@ class PodcastEpisodeDownload { this.url = null this.libraryItem = null + this.isAutoDownload = false this.isDownloading = false this.isFinished = false this.failed = false @@ -46,11 +47,12 @@ class PodcastEpisodeDownload { return this.libraryItem ? this.libraryItem.id : null } - setData(podcastEpisode, libraryItem) { + setData(podcastEpisode, libraryItem, isAutoDownload) { this.id = getId('epdl') this.podcastEpisode = podcastEpisode this.url = podcastEpisode.enclosure.url this.libraryItem = libraryItem + this.isAutoDownload = isAutoDownload this.createdAt = Date.now() } diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 0dd3cafb..bf6d0f67 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -19,6 +19,7 @@ class Podcast { this.autoDownloadEpisodes = false this.lastEpisodeCheck = 0 + this.maxEpisodesToKeep = 0 this.lastCoverSearch = null this.lastCoverSearchQuery = null @@ -40,6 +41,7 @@ class Podcast { }) this.autoDownloadEpisodes = !!podcast.autoDownloadEpisodes this.lastEpisodeCheck = podcast.lastEpisodeCheck || 0 + this.maxEpisodesToKeep = podcast.maxEpisodesToKeep || 0 } toJSON() { @@ -50,7 +52,8 @@ class Podcast { tags: [...this.tags], episodes: this.episodes.map(e => e.toJSON()), autoDownloadEpisodes: this.autoDownloadEpisodes, - lastEpisodeCheck: this.lastEpisodeCheck + lastEpisodeCheck: this.lastEpisodeCheck, + maxEpisodesToKeep: this.maxEpisodesToKeep } } @@ -62,6 +65,7 @@ class Podcast { numEpisodes: this.episodes.length, autoDownloadEpisodes: this.autoDownloadEpisodes, lastEpisodeCheck: this.lastEpisodeCheck, + maxEpisodesToKeep: this.maxEpisodesToKeep, size: this.size } } @@ -75,6 +79,7 @@ class Podcast { episodes: this.episodes.map(e => e.toJSONExpanded()), autoDownloadEpisodes: this.autoDownloadEpisodes, lastEpisodeCheck: this.lastEpisodeCheck, + maxEpisodesToKeep: this.maxEpisodesToKeep, size: this.size } } @@ -113,6 +118,9 @@ class Podcast { }) return largestPublishedAt } + get episodesWithPubDate() { + return this.episodes.filter(ep => !!ep.publishedAt) + } update(payload) { var json = this.toJSON() diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index f487a209..6915c287 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -16,7 +16,7 @@ async function getFileStat(path) { birthtime: stat.birthtime } } catch (err) { - console.error('Failed to stat', err) + Logger.error('[fileUtils] Failed to stat', err) return false } } @@ -33,7 +33,7 @@ async function getFileTimestampsWithIno(path) { ino: String(stat.ino) } } catch (err) { - console.error('Failed to getFileTimestampsWithIno', err) + Logger.error('[fileUtils] Failed to getFileTimestampsWithIno', err) return false } } @@ -219,4 +219,12 @@ module.exports.getAudioMimeTypeFromExtname = (extname) => { const formatUpper = extname.slice(1).toUpperCase() if (AudioMimeType[formatUpper]) return AudioMimeType[formatUpper] return null +} + +module.exports.removeFile = (path) => { + if (!path) return false + return fs.remove(path).then(() => true).catch((error) => { + Logger.error(`[fileUtils] Failed remove file "${path}"`, error) + return false + }) } \ No newline at end of file