mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Merge pull request #3789 from advplyr/migrate-podcasts-new-library-item
Update podcasts to new library item model
This commit is contained in:
		
						commit
						4a398f6113
					
				| @ -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) | ||||
|     } | ||||
|   }, | ||||
|  | ||||
| @ -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 | ||||
|     } | ||||
|   }, | ||||
|  | ||||
| @ -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() | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -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
 | ||||
|  | ||||
| @ -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<boolean>} - 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 | ||||
|   } | ||||
|  | ||||
| @ -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 | ||||
|    */ | ||||
|  | ||||
| @ -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 | ||||
|    * | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -167,10 +167,5 @@ class PodcastEpisode { | ||||
|     } | ||||
|     return hasUpdates | ||||
|   } | ||||
| 
 | ||||
|   checkEqualsEnclosureUrl(url) { | ||||
|     if (!this.enclosure?.url) return false | ||||
|     return this.enclosure.url == url | ||||
|   } | ||||
| } | ||||
| module.exports = PodcastEpisode | ||||
|  | ||||
| @ -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) | ||||
|  | ||||
| @ -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) { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user