diff --git a/client/components/modals/podcast/EditEpisode.vue b/client/components/modals/podcast/EditEpisode.vue index b87f89c7..9702ce38 100644 --- a/client/components/modals/podcast/EditEpisode.vue +++ b/client/components/modals/podcast/EditEpisode.vue @@ -170,6 +170,12 @@ export default { this.show = false } }, + libraryItemUpdated(libraryItem) { + const episode = libraryItem.media.episodes.find((e) => e.id === this.selectedEpisodeId) + if (episode) { + this.episodeItem = episode + } + }, hotkey(action) { if (action === this.$hotkeys.Modal.NEXT_PAGE) { this.goNextEpisode() @@ -178,9 +184,15 @@ export default { } }, registerListeners() { + if (this.libraryItem) { + this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated) + } this.$eventBus.$on('modal-hotkey', this.hotkey) }, unregisterListeners() { + if (this.libraryItem) { + this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated) + } this.$eventBus.$off('modal-hotkey', this.hotkey) } }, diff --git a/client/components/modals/podcast/tabs/EpisodeDetails.vue b/client/components/modals/podcast/tabs/EpisodeDetails.vue index 2084ddee..85cfb4ff 100644 --- a/client/components/modals/podcast/tabs/EpisodeDetails.vue +++ b/client/components/modals/podcast/tabs/EpisodeDetails.vue @@ -163,13 +163,10 @@ export default { this.isProcessing = false if (updateResult) { - if (updateResult) { - this.$toast.success(this.$strings.ToastItemUpdateSuccess) - return true - } else { - this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary) - } + this.$toast.success(this.$strings.ToastItemUpdateSuccess) + return true } + return false } }, diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 3610c2ea..c62742a5 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -19,6 +19,11 @@ const LibraryItem = require('../objects/LibraryItem') * @property {import('../models/User')} user * * @typedef {Request & RequestUserObject} RequestWithUser + * + * @typedef RequestEntityObject + * @property {import('../models/LibraryItem')} libraryItem + * + * @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem */ class PodcastController { @@ -112,11 +117,6 @@ class PodcastController { res.json(libraryItem.toJSONExpanded()) - if (payload.episodesToDownload?.length) { - Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`) - this.podcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload) - } - // Turn on podcast auto download cron if not already on if (libraryItem.media.autoDownloadEpisodes) { this.cronManager.checkUpdatePodcastCron(libraryItem) @@ -213,7 +213,7 @@ class PodcastController { * * @this import('../routers/ApiRouter') * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async checkNewEpisodes(req, res) { @@ -222,15 +222,14 @@ class PodcastController { return res.sendStatus(403) } - var libraryItem = req.libraryItem - if (!libraryItem.media.metadata.feedUrl) { - Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`) - return res.status(500).send('Podcast has no rss feed url') + if (!req.libraryItem.media.feedURL) { + Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${req.libraryItem.id}`) + return res.status(400).send('Podcast has no rss feed url') } const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3 - var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) + const newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(req.libraryItem, maxEpisodesToDownload) res.json({ episodes: newEpisodes || [] }) @@ -258,23 +257,28 @@ class PodcastController { * * @this {import('../routers/ApiRouter')} * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ getEpisodeDownloads(req, res) { - var libraryItem = req.libraryItem - - var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id) + const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id) res.json({ downloads: downloadsInQueue.map((d) => d.toJSONForClient()) }) } + /** + * GET: /api/podcasts/:id/search-episode + * Search for an episode in a podcast + * + * @param {RequestWithLibraryItem} req + * @param {Response} res + */ async findEpisode(req, res) { - const rssFeedUrl = req.libraryItem.media.metadata.feedUrl + const rssFeedUrl = req.libraryItem.media.feedURL if (!rssFeedUrl) { Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`) - return res.status(500).send('Podcast does not have an RSS feed URL') + return res.status(400).send('Podcast does not have an RSS feed URL') } const searchTitle = req.query.title @@ -292,7 +296,7 @@ class PodcastController { * * @this {import('../routers/ApiRouter')} * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async downloadEpisodes(req, res) { @@ -300,13 +304,13 @@ class PodcastController { Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`) return res.sendStatus(403) } - const libraryItem = req.libraryItem + const episodes = req.body - if (!episodes?.length) { + if (!Array.isArray(episodes) || !episodes.length) { return res.sendStatus(400) } - this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes) + this.podcastManager.downloadPodcastEpisodes(req.libraryItem, episodes) res.sendStatus(200) } @@ -315,7 +319,7 @@ class PodcastController { * * @this {import('../routers/ApiRouter')} * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async quickMatchEpisodes(req, res) { @@ -325,10 +329,11 @@ class PodcastController { } const overrideDetails = req.query.override === '1' - const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails }) + const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) + const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(oldLibraryItem, { overrideDetails }) if (episodesUpdated) { - await Database.updateLibraryItem(req.libraryItem) - SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) + await Database.updateLibraryItem(oldLibraryItem) + SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) } res.json({ @@ -339,58 +344,76 @@ class PodcastController { /** * PATCH: /api/podcasts/:id/episode/:episodeId * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async updateEpisode(req, res) { - const libraryItem = req.libraryItem - - var episodeId = req.params.episodeId - if (!libraryItem.media.checkHasEpisode(episodeId)) { + /** @type {import('../models/PodcastEpisode')} */ + const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === req.params.episodeId) + if (!episode) { return res.status(404).send('Episode not found') } - if (libraryItem.media.updateEpisode(episodeId, req.body)) { - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + const updatePayload = {} + const supportedStringKeys = ['title', 'subtitle', 'description', 'pubDate', 'episode', 'season', 'episodeType'] + for (const key in req.body) { + if (supportedStringKeys.includes(key) && typeof req.body[key] === 'string') { + updatePayload[key] = req.body[key] + } else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) { + updatePayload[key] = req.body[key] + } else if (key === 'publishedAt' && typeof req.body[key] === 'number') { + updatePayload[key] = req.body[key] + } } - res.json(libraryItem.toJSONExpanded()) + if (Object.keys(updatePayload).length) { + episode.set(updatePayload) + if (episode.changed()) { + Logger.info(`[PodcastController] Updated episode "${episode.title}" keys`, episode.changed()) + await episode.save() + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) + } else { + Logger.info(`[PodcastController] No changes to episode "${episode.title}"`) + } + } + + res.json(req.libraryItem.toOldJSONExpanded()) } /** * GET: /api/podcasts/:id/episode/:episodeId * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async getEpisode(req, res) { const episodeId = req.params.episodeId - const libraryItem = req.libraryItem - const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId) + /** @type {import('../models/PodcastEpisode')} */ + const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId) if (!episode) { - Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${libraryItem.id}`) + Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`) return res.sendStatus(404) } - res.json(episode) + res.json(episode.toOldJSON(req.libraryItem.id)) } /** * DELETE: /api/podcasts/:id/episode/:episodeId * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async removeEpisode(req, res) { const episodeId = req.params.episodeId - const libraryItem = req.libraryItem const hardDelete = req.query.hard === '1' - const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId) + /** @type {import('../models/PodcastEpisode')} */ + const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId) if (!episode) { - Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`) + Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`) return res.sendStatus(404) } @@ -407,36 +430,8 @@ class PodcastController { }) } - // Remove episode from Podcast and library file - const episodeRemoved = libraryItem.media.removeEpisode(episodeId) - if (episodeRemoved?.audioFile) { - libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino) - } - - // Update/remove playlists that had this podcast episode - const playlistMediaItems = await Database.playlistMediaItemModel.findAll({ - where: { - mediaItemId: episodeId - }, - include: { - model: Database.playlistModel, - include: Database.playlistMediaItemModel - } - }) - for (const pmi of playlistMediaItems) { - const numItems = pmi.playlist.playlistMediaItems.length - 1 - - if (!numItems) { - Logger.info(`[PodcastController] Playlist "${pmi.playlist.name}" has no more items - removing it`) - const jsonExpanded = await pmi.playlist.getOldJsonExpanded() - SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded) - await pmi.playlist.destroy() - } else { - await pmi.destroy() - const jsonExpanded = await pmi.playlist.getOldJsonExpanded() - SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_updated', jsonExpanded) - } - } + // Remove episode from playlists + await Database.playlistModel.removeMediaItemsFromPlaylists([episodeId]) // Remove media progress for this episode const mediaProgressRemoved = await Database.mediaProgressModel.destroy({ @@ -448,9 +443,16 @@ class PodcastController { Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`) } - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) - res.json(libraryItem.toJSON()) + // Remove episode + await episode.destroy() + + // Remove library file + req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((file) => file.ino !== episode.audioFile.ino) + req.libraryItem.changed('libraryFiles', true) + await req.libraryItem.save() + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) + res.json(req.libraryItem.toOldJSON()) } /** @@ -460,15 +462,15 @@ class PodcastController { * @param {NextFunction} next */ async middleware(req, res, next) { - const item = await Database.libraryItemModel.getOldById(req.params.id) - if (!item?.media) return res.sendStatus(404) + const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id) + if (!libraryItem?.media) return res.sendStatus(404) - if (!item.isPodcast) { + if (!libraryItem.isPodcast) { return res.sendStatus(500) } // Check user can access this library item - if (!req.user.checkCanAccessLibraryItem(item)) { + if (!req.user.checkCanAccessLibraryItem(libraryItem)) { return res.sendStatus(403) } @@ -480,7 +482,7 @@ class PodcastController { return res.sendStatus(403) } - req.libraryItem = item + req.libraryItem = libraryItem next() } } diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index a4dbe6b4..c61fb049 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -181,7 +181,7 @@ class CronManager { // Get podcast library items to check const libraryItems = [] for (const libraryItemId of libraryItemIds) { - const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId) + const libraryItem = await Database.libraryItemModel.getExpandedById(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 diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 456927c8..92053707 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -52,11 +52,16 @@ class PodcastManager { } } + /** + * + * @param {import('../models/LibraryItem')} libraryItem + * @param {*} episodesToDownload + * @param {*} isAutoDownload + */ async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) { - let index = Math.max(...libraryItem.media.episodes.filter((ep) => ep.index == null || isNaN(ep.index)).map((ep) => Number(ep.index))) + 1 for (const ep of episodesToDownload) { const newPe = new PodcastEpisode() - newPe.setData(ep, index++) + newPe.setData(ep, null) newPe.libraryItemId = libraryItem.id newPe.podcastId = libraryItem.media.id const newPeDl = new PodcastEpisodeDownload() @@ -263,16 +268,21 @@ class PodcastManager { return newAudioFile } - // Returns false if auto download episodes was disabled (disabled if reaches max failed checks) + /** + * + * @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 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'}`) + 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 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)}`) + Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`) var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload) Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`) @@ -283,36 +293,47 @@ class PodcastManager { 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`) + 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.metadata.title}"`) + 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.metadata.title}" - starting download`) + 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.metadata.title}"`) + Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.title}"`) } - libraryItem.media.lastEpisodeCheck = Date.now() - libraryItem.updatedAt = Date.now() - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + 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 + */ async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) { - if (!podcastLibraryItem.media.metadata.feedUrl) { - Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`) + if (!podcastLibraryItem.media.feedURL) { + Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`) return false } - const feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl) + const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL) if (!feed?.episodes) { - Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed) + Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed) return false } @@ -326,21 +347,32 @@ class PodcastManager { return newEpisodes } + /** + * + * @param {import('../models/LibraryItem')} libraryItem + * @param {*} maxEpisodesToDownload + * @returns + */ async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) { - 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, 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}`) + + var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, lastEpisodeCheck, maxEpisodesToDownload) if (newEpisodes.length) { - Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`) + 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.metadata.title}"`) + Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.title}"`) } - libraryItem.media.lastEpisodeCheck = Date.now() - libraryItem.updatedAt = Date.now() - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + libraryItem.media.lastEpisodeCheck = new Date() + await libraryItem.media.save() + + libraryItem.changed('updatedAt', true) + await libraryItem.save() + + SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) return newEpisodes } diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 188c1070..fd471305 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -259,6 +259,10 @@ class Podcast extends Model { this.autoDownloadSchedule = payload.autoDownloadSchedule hasUpdates = true } + if (typeof payload.lastEpisodeCheck === 'number' && payload.lastEpisodeCheck !== this.lastEpisodeCheck?.valueOf()) { + this.lastEpisodeCheck = payload.lastEpisodeCheck + hasUpdates = true + } const numberKeys = ['maxEpisodesToKeep', 'maxNewEpisodesToDownload'] numberKeys.forEach((key) => { @@ -348,6 +352,31 @@ class Podcast extends Model { return episode.duration } + /** + * + * @returns {number} - Unix timestamp + */ + getLatestEpisodePublishedAt() { + return this.podcastEpisodes.reduce((latest, episode) => { + if (episode.publishedAt?.valueOf() > latest) { + return episode.publishedAt.valueOf() + } + return latest + }, 0) + } + + /** + * Used for checking if an rss feed episode is already in the podcast + * + * @param {Object} feedEpisode - object from rss feed + * @returns {boolean} + */ + checkHasEpisodeByFeedEpisode(feedEpisode) { + const guid = feedEpisode.guid + const url = feedEpisode.enclosure.url + return this.podcastEpisodes.some((ep) => ep.checkMatchesGuidOrEnclosureUrl(guid, url)) + } + /** * Old model kept metadata in a separate object */ diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 24d07041..4c9967f8 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -143,6 +143,23 @@ class PodcastEpisode extends Model { return this.audioFile?.duration || 0 } + /** + * Used for matching the episode with an episode in the RSS feed + * + * @param {string} guid + * @param {string} enclosureURL + * @returns {boolean} + */ + checkMatchesGuidOrEnclosureUrl(guid, enclosureURL) { + if (this.extraData?.guid && this.extraData.guid === guid) { + return true + } + if (this.enclosureURL && this.enclosureURL === enclosureURL) { + return true + } + return false + } + /** * Used in client players * diff --git a/server/objects/PodcastEpisodeDownload.js b/server/objects/PodcastEpisodeDownload.js index eb9f059a..ecda4a47 100644 --- a/server/objects/PodcastEpisodeDownload.js +++ b/server/objects/PodcastEpisodeDownload.js @@ -6,8 +6,10 @@ const globals = require('../utils/globals') class PodcastEpisodeDownload { constructor() { this.id = null + /** @type {import('../objects/entities/PodcastEpisode')} */ this.podcastEpisode = null this.url = null + /** @type {import('../models/LibraryItem')} */ this.libraryItem = null this.libraryId = null @@ -27,7 +29,7 @@ class PodcastEpisodeDownload { id: this.id, episodeDisplayTitle: this.podcastEpisode?.title ?? null, url: this.url, - libraryItemId: this.libraryItem?.id || null, + libraryItemId: this.libraryItemId, libraryId: this.libraryId || null, isFinished: this.isFinished, failed: this.failed, @@ -35,8 +37,8 @@ class PodcastEpisodeDownload { startedAt: this.startedAt, createdAt: this.createdAt, finishedAt: this.finishedAt, - podcastTitle: this.libraryItem?.media.metadata.title ?? null, - podcastExplicit: !!this.libraryItem?.media.metadata.explicit, + podcastTitle: this.libraryItem?.media.title ?? null, + podcastExplicit: !!this.libraryItem?.media.explicit, season: this.podcastEpisode?.season ?? null, episode: this.podcastEpisode?.episode ?? null, episodeType: this.podcastEpisode?.episodeType ?? 'full', @@ -80,9 +82,16 @@ class PodcastEpisodeDownload { return this.targetFilename } get libraryItemId() { - return this.libraryItem ? this.libraryItem.id : null + return this.libraryItem?.id || null } + /** + * + * @param {import('../objects/entities/PodcastEpisode')} podcastEpisode - old model + * @param {import('../models/LibraryItem')} libraryItem + * @param {*} isAutoDownload + * @param {*} libraryId + */ setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) { this.id = uuidv4() this.podcastEpisode = podcastEpisode diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 945e0e56..e759a0eb 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -167,10 +167,5 @@ class PodcastEpisode { } return hasUpdates } - - checkEqualsEnclosureUrl(url) { - if (!this.enclosure?.url) return false - return this.enclosure.url == url - } } module.exports = PodcastEpisode diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 2a009eb2..8d6b541d 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -193,11 +193,6 @@ class Podcast { checkHasEpisode(episodeId) { return this.episodes.some((ep) => ep.id === episodeId) } - checkHasEpisodeByFeedEpisode(feedEpisode) { - const guid = feedEpisode.guid - const url = feedEpisode.enclosure.url - return this.episodes.some((ep) => (ep.guid && ep.guid === guid) || ep.checkEqualsEnclosureUrl(url)) - } addPodcastEpisode(podcastEpisode) { this.episodes.push(podcastEpisode) diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index c7024225..06e20f1d 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -97,6 +97,11 @@ async function resizeImage(filePath, outputPath, width, height) { } module.exports.resizeImage = resizeImage +/** + * + * @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload + * @returns + */ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { return new Promise(async (resolve) => { const response = await axios({ @@ -118,21 +123,22 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { ffmpeg.addOption('-loglevel debug') // Debug logs printed on error ffmpeg.outputOptions('-c:a', 'copy', '-map', '0:a', '-metadata', 'podcast=1') - const podcastMetadata = podcastEpisodeDownload.libraryItem.media.metadata + /** @type {import('../models/Podcast')} */ + const podcast = podcastEpisodeDownload.libraryItem.media const podcastEpisode = podcastEpisodeDownload.podcastEpisode const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0) const taggings = { - album: podcastMetadata.title, - 'album-sort': podcastMetadata.title, - artist: podcastMetadata.author, - 'artist-sort': podcastMetadata.author, + album: podcast.title, + 'album-sort': podcast.title, + artist: podcast.author, + 'artist-sort': podcast.author, comment: podcastEpisode.description, subtitle: podcastEpisode.subtitle, disc: podcastEpisode.season, - genre: podcastMetadata.genres.length ? podcastMetadata.genres.join(';') : null, - language: podcastMetadata.language, - MVNM: podcastMetadata.title, + genre: podcast.genres.length ? podcast.genres.join(';') : null, + language: podcast.language, + MVNM: podcast.title, MVIN: podcastEpisode.episode, track: podcastEpisode.episode, 'series-part': podcastEpisode.episode, @@ -141,9 +147,9 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { year: podcastEpisode.pubYear, date: podcastEpisode.pubDate, releasedate: podcastEpisode.pubDate, - 'itunes-id': podcastMetadata.itunesId, - 'podcast-type': podcastMetadata.type, - 'episode-type': podcastMetadata.episodeType + 'itunes-id': podcast.itunesId, + 'podcast-type': podcast.podcastType, + 'episode-type': podcastEpisode.episodeType } for (const tag in taggings) {