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)
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
@ -162,14 +162,11 @@ export default {
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      this.isProcessing = false
 | 
			
		||||
      if (updateResult) {
 | 
			
		||||
      if (updateResult) {
 | 
			
		||||
        this.$toast.success(this.$strings.ToastItemUpdateSuccess)
 | 
			
		||||
        return true
 | 
			
		||||
        } else {
 | 
			
		||||
          this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      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