Update podcasts to new library item model

This commit is contained in:
advplyr 2025-01-03 16:48:24 -06:00
parent 0357dc90d4
commit 69d1744496
11 changed files with 235 additions and 141 deletions

View File

@ -170,6 +170,12 @@ export default {
this.show = false this.show = false
} }
}, },
libraryItemUpdated(libraryItem) {
const episode = libraryItem.media.episodes.find((e) => e.id === this.selectedEpisodeId)
if (episode) {
this.episodeItem = episode
}
},
hotkey(action) { hotkey(action) {
if (action === this.$hotkeys.Modal.NEXT_PAGE) { if (action === this.$hotkeys.Modal.NEXT_PAGE) {
this.goNextEpisode() this.goNextEpisode()
@ -178,9 +184,15 @@ export default {
} }
}, },
registerListeners() { registerListeners() {
if (this.libraryItem) {
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
}
this.$eventBus.$on('modal-hotkey', this.hotkey) this.$eventBus.$on('modal-hotkey', this.hotkey)
}, },
unregisterListeners() { unregisterListeners() {
if (this.libraryItem) {
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
}
this.$eventBus.$off('modal-hotkey', this.hotkey) this.$eventBus.$off('modal-hotkey', this.hotkey)
} }
}, },

View File

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

View File

@ -19,6 +19,11 @@ const LibraryItem = require('../objects/LibraryItem')
* @property {import('../models/User')} user * @property {import('../models/User')} user
* *
* @typedef {Request & RequestUserObject} RequestWithUser * @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/LibraryItem')} libraryItem
*
* @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem
*/ */
class PodcastController { class PodcastController {
@ -112,11 +117,6 @@ class PodcastController {
res.json(libraryItem.toJSONExpanded()) 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 // Turn on podcast auto download cron if not already on
if (libraryItem.media.autoDownloadEpisodes) { if (libraryItem.media.autoDownloadEpisodes) {
this.cronManager.checkUpdatePodcastCron(libraryItem) this.cronManager.checkUpdatePodcastCron(libraryItem)
@ -213,7 +213,7 @@ class PodcastController {
* *
* @this import('../routers/ApiRouter') * @this import('../routers/ApiRouter')
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async checkNewEpisodes(req, res) { async checkNewEpisodes(req, res) {
@ -222,15 +222,14 @@ class PodcastController {
return res.sendStatus(403) return res.sendStatus(403)
} }
var libraryItem = req.libraryItem if (!req.libraryItem.media.feedURL) {
if (!libraryItem.media.metadata.feedUrl) { Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${req.libraryItem.id}`)
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`) return res.status(400).send('Podcast has no rss feed url')
return res.status(500).send('Podcast has no rss feed url')
} }
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3 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({ res.json({
episodes: newEpisodes || [] episodes: newEpisodes || []
}) })
@ -258,23 +257,28 @@ class PodcastController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
getEpisodeDownloads(req, res) { getEpisodeDownloads(req, res) {
var libraryItem = req.libraryItem const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
res.json({ res.json({
downloads: downloadsInQueue.map((d) => d.toJSONForClient()) 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) { async findEpisode(req, res) {
const rssFeedUrl = req.libraryItem.media.metadata.feedUrl const rssFeedUrl = req.libraryItem.media.feedURL
if (!rssFeedUrl) { if (!rssFeedUrl) {
Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`) 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 const searchTitle = req.query.title
@ -292,7 +296,7 @@ class PodcastController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async downloadEpisodes(req, res) { async downloadEpisodes(req, res) {
@ -300,13 +304,13 @@ class PodcastController {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`) Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`)
return res.sendStatus(403) return res.sendStatus(403)
} }
const libraryItem = req.libraryItem
const episodes = req.body const episodes = req.body
if (!episodes?.length) { if (!Array.isArray(episodes) || !episodes.length) {
return res.sendStatus(400) return res.sendStatus(400)
} }
this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes) this.podcastManager.downloadPodcastEpisodes(req.libraryItem, episodes)
res.sendStatus(200) res.sendStatus(200)
} }
@ -315,7 +319,7 @@ class PodcastController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async quickMatchEpisodes(req, res) { async quickMatchEpisodes(req, res) {
@ -325,10 +329,11 @@ class PodcastController {
} }
const overrideDetails = req.query.override === '1' 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) { if (episodesUpdated) {
await Database.updateLibraryItem(req.libraryItem) await Database.updateLibraryItem(oldLibraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
} }
res.json({ res.json({
@ -339,58 +344,76 @@ class PodcastController {
/** /**
* PATCH: /api/podcasts/:id/episode/:episodeId * PATCH: /api/podcasts/:id/episode/:episodeId
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async updateEpisode(req, res) { async updateEpisode(req, res) {
const libraryItem = req.libraryItem /** @type {import('../models/PodcastEpisode')} */
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === req.params.episodeId)
var episodeId = req.params.episodeId if (!episode) {
if (!libraryItem.media.checkHasEpisode(episodeId)) {
return res.status(404).send('Episode not found') return res.status(404).send('Episode not found')
} }
if (libraryItem.media.updateEpisode(episodeId, req.body)) { const updatePayload = {}
await Database.updateLibraryItem(libraryItem) const supportedStringKeys = ['title', 'subtitle', 'description', 'pubDate', 'episode', 'season', 'episodeType']
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) 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 * GET: /api/podcasts/:id/episode/:episodeId
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async getEpisode(req, res) { async getEpisode(req, res) {
const episodeId = req.params.episodeId 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) { 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) return res.sendStatus(404)
} }
res.json(episode) res.json(episode.toOldJSON(req.libraryItem.id))
} }
/** /**
* DELETE: /api/podcasts/:id/episode/:episodeId * DELETE: /api/podcasts/:id/episode/:episodeId
* *
* @param {RequestWithUser} req * @param {RequestWithLibraryItem} req
* @param {Response} res * @param {Response} res
*/ */
async removeEpisode(req, res) { async removeEpisode(req, res) {
const episodeId = req.params.episodeId const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
const hardDelete = req.query.hard === '1' 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) { 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) return res.sendStatus(404)
} }
@ -407,36 +430,8 @@ class PodcastController {
}) })
} }
// Remove episode from Podcast and library file // Remove episode from playlists
const episodeRemoved = libraryItem.media.removeEpisode(episodeId) await Database.playlistModel.removeMediaItemsFromPlaylists([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 media progress for this episode // Remove media progress for this episode
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({ const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
@ -448,9 +443,16 @@ class PodcastController {
Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`) Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`)
} }
await Database.updateLibraryItem(libraryItem) // Remove episode
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) await episode.destroy()
res.json(libraryItem.toJSON())
// 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 * @param {NextFunction} next
*/ */
async middleware(req, res, next) { async middleware(req, res, next) {
const item = await Database.libraryItemModel.getOldById(req.params.id) const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!item?.media) return res.sendStatus(404) if (!libraryItem?.media) return res.sendStatus(404)
if (!item.isPodcast) { if (!libraryItem.isPodcast) {
return res.sendStatus(500) return res.sendStatus(500)
} }
// Check user can access this library item // Check user can access this library item
if (!req.user.checkCanAccessLibraryItem(item)) { if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
return res.sendStatus(403) return res.sendStatus(403)
} }
@ -480,7 +482,7 @@ class PodcastController {
return res.sendStatus(403) return res.sendStatus(403)
} }
req.libraryItem = item req.libraryItem = libraryItem
next() next()
} }
} }

View File

@ -181,7 +181,7 @@ class CronManager {
// Get podcast library items to check // Get podcast library items to check
const libraryItems = [] const libraryItems = []
for (const libraryItemId of libraryItemIds) { for (const libraryItemId of libraryItemIds) {
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId) const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`) Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItemId) // Filter it out podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItemId) // Filter it out

View File

@ -52,11 +52,16 @@ class PodcastManager {
} }
} }
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} episodesToDownload
* @param {*} isAutoDownload
*/
async downloadPodcastEpisodes(libraryItem, episodesToDownload, 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) { for (const ep of episodesToDownload) {
const newPe = new PodcastEpisode() const newPe = new PodcastEpisode()
newPe.setData(ep, index++) newPe.setData(ep, null)
newPe.libraryItemId = libraryItem.id newPe.libraryItemId = libraryItem.id
newPe.podcastId = libraryItem.media.id newPe.podcastId = libraryItem.media.id
const newPeDl = new PodcastEpisodeDownload() const newPeDl = new PodcastEpisodeDownload()
@ -263,16 +268,21 @@ class PodcastManager {
return newAudioFile 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) { async runEpisodeCheck(libraryItem) {
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0) const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished const latestEpisodePublishedAt = libraryItem.media.getLatestEpisodePublishedAt()
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
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 // Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
// lastEpisodeCheckDate will be the current time when adding a new podcast // lastEpisodeCheckDate will be the current time when adding a new podcast
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate 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) var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)
Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`) 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 if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
this.failedCheckMap[libraryItem.id]++ this.failedCheckMap[libraryItem.id]++
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) { 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 libraryItem.media.autoDownloadEpisodes = false
delete this.failedCheckMap[libraryItem.id] delete this.failedCheckMap[libraryItem.id]
} else { } 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) { } else if (newEpisodes.length) {
delete this.failedCheckMap[libraryItem.id] 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) this.downloadPodcastEpisodes(libraryItem, newEpisodes, true)
} else { } else {
delete this.failedCheckMap[libraryItem.id] 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.media.lastEpisodeCheck = new Date()
libraryItem.updatedAt = Date.now() await libraryItem.media.save()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) libraryItem.changed('updatedAt', true)
await libraryItem.save()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
return libraryItem.media.autoDownloadEpisodes return libraryItem.media.autoDownloadEpisodes
} }
/**
*
* @param {import('../models/LibraryItem')} podcastLibraryItem
* @param {number} dateToCheckForEpisodesAfter - Unix timestamp
* @param {number} maxNewEpisodes
* @returns
*/
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) { async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) {
if (!podcastLibraryItem.media.metadata.feedUrl) { if (!podcastLibraryItem.media.feedURL) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`) Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`)
return false return false
} }
const feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl) const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL)
if (!feed?.episodes) { 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 return false
} }
@ -326,21 +347,32 @@ class PodcastManager {
return newEpisodes return newEpisodes
} }
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} maxEpisodesToDownload
* @returns
*/
async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) { async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) {
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0) const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`) const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem.media.lastEpisodeCheck : 'Never'
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck, maxEpisodesToDownload) Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.title}" - Last episode check: ${lastEpisodeCheckDate}`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, lastEpisodeCheck, maxEpisodesToDownload)
if (newEpisodes.length) { 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) this.downloadPodcastEpisodes(libraryItem, newEpisodes, false)
} else { } 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.media.lastEpisodeCheck = new Date()
libraryItem.updatedAt = Date.now() await libraryItem.media.save()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) libraryItem.changed('updatedAt', true)
await libraryItem.save()
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
return newEpisodes return newEpisodes
} }

View File

@ -259,6 +259,10 @@ class Podcast extends Model {
this.autoDownloadSchedule = payload.autoDownloadSchedule this.autoDownloadSchedule = payload.autoDownloadSchedule
hasUpdates = true hasUpdates = true
} }
if (typeof payload.lastEpisodeCheck === 'number' && payload.lastEpisodeCheck !== this.lastEpisodeCheck?.valueOf()) {
this.lastEpisodeCheck = payload.lastEpisodeCheck
hasUpdates = true
}
const numberKeys = ['maxEpisodesToKeep', 'maxNewEpisodesToDownload'] const numberKeys = ['maxEpisodesToKeep', 'maxNewEpisodesToDownload']
numberKeys.forEach((key) => { numberKeys.forEach((key) => {
@ -348,6 +352,31 @@ class Podcast extends Model {
return episode.duration 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 * Old model kept metadata in a separate object
*/ */

View File

@ -143,6 +143,23 @@ class PodcastEpisode extends Model {
return this.audioFile?.duration || 0 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 * Used in client players
* *

View File

@ -6,8 +6,10 @@ const globals = require('../utils/globals')
class PodcastEpisodeDownload { class PodcastEpisodeDownload {
constructor() { constructor() {
this.id = null this.id = null
/** @type {import('../objects/entities/PodcastEpisode')} */
this.podcastEpisode = null this.podcastEpisode = null
this.url = null this.url = null
/** @type {import('../models/LibraryItem')} */
this.libraryItem = null this.libraryItem = null
this.libraryId = null this.libraryId = null
@ -27,7 +29,7 @@ class PodcastEpisodeDownload {
id: this.id, id: this.id,
episodeDisplayTitle: this.podcastEpisode?.title ?? null, episodeDisplayTitle: this.podcastEpisode?.title ?? null,
url: this.url, url: this.url,
libraryItemId: this.libraryItem?.id || null, libraryItemId: this.libraryItemId,
libraryId: this.libraryId || null, libraryId: this.libraryId || null,
isFinished: this.isFinished, isFinished: this.isFinished,
failed: this.failed, failed: this.failed,
@ -35,8 +37,8 @@ class PodcastEpisodeDownload {
startedAt: this.startedAt, startedAt: this.startedAt,
createdAt: this.createdAt, createdAt: this.createdAt,
finishedAt: this.finishedAt, finishedAt: this.finishedAt,
podcastTitle: this.libraryItem?.media.metadata.title ?? null, podcastTitle: this.libraryItem?.media.title ?? null,
podcastExplicit: !!this.libraryItem?.media.metadata.explicit, podcastExplicit: !!this.libraryItem?.media.explicit,
season: this.podcastEpisode?.season ?? null, season: this.podcastEpisode?.season ?? null,
episode: this.podcastEpisode?.episode ?? null, episode: this.podcastEpisode?.episode ?? null,
episodeType: this.podcastEpisode?.episodeType ?? 'full', episodeType: this.podcastEpisode?.episodeType ?? 'full',
@ -80,9 +82,16 @@ class PodcastEpisodeDownload {
return this.targetFilename return this.targetFilename
} }
get libraryItemId() { 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) { setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) {
this.id = uuidv4() this.id = uuidv4()
this.podcastEpisode = podcastEpisode this.podcastEpisode = podcastEpisode

View File

@ -167,10 +167,5 @@ class PodcastEpisode {
} }
return hasUpdates return hasUpdates
} }
checkEqualsEnclosureUrl(url) {
if (!this.enclosure?.url) return false
return this.enclosure.url == url
}
} }
module.exports = PodcastEpisode module.exports = PodcastEpisode

View File

@ -193,11 +193,6 @@ class Podcast {
checkHasEpisode(episodeId) { checkHasEpisode(episodeId) {
return this.episodes.some((ep) => ep.id === 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) { addPodcastEpisode(podcastEpisode) {
this.episodes.push(podcastEpisode) this.episodes.push(podcastEpisode)

View File

@ -97,6 +97,11 @@ async function resizeImage(filePath, outputPath, width, height) {
} }
module.exports.resizeImage = resizeImage module.exports.resizeImage = resizeImage
/**
*
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
* @returns
*/
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
const response = await axios({ const response = await axios({
@ -118,21 +123,22 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
ffmpeg.addOption('-loglevel debug') // Debug logs printed on error ffmpeg.addOption('-loglevel debug') // Debug logs printed on error
ffmpeg.outputOptions('-c:a', 'copy', '-map', '0:a', '-metadata', 'podcast=1') 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 podcastEpisode = podcastEpisodeDownload.podcastEpisode
const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0) const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0)
const taggings = { const taggings = {
album: podcastMetadata.title, album: podcast.title,
'album-sort': podcastMetadata.title, 'album-sort': podcast.title,
artist: podcastMetadata.author, artist: podcast.author,
'artist-sort': podcastMetadata.author, 'artist-sort': podcast.author,
comment: podcastEpisode.description, comment: podcastEpisode.description,
subtitle: podcastEpisode.subtitle, subtitle: podcastEpisode.subtitle,
disc: podcastEpisode.season, disc: podcastEpisode.season,
genre: podcastMetadata.genres.length ? podcastMetadata.genres.join(';') : null, genre: podcast.genres.length ? podcast.genres.join(';') : null,
language: podcastMetadata.language, language: podcast.language,
MVNM: podcastMetadata.title, MVNM: podcast.title,
MVIN: podcastEpisode.episode, MVIN: podcastEpisode.episode,
track: podcastEpisode.episode, track: podcastEpisode.episode,
'series-part': podcastEpisode.episode, 'series-part': podcastEpisode.episode,
@ -141,9 +147,9 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
year: podcastEpisode.pubYear, year: podcastEpisode.pubYear,
date: podcastEpisode.pubDate, date: podcastEpisode.pubDate,
releasedate: podcastEpisode.pubDate, releasedate: podcastEpisode.pubDate,
'itunes-id': podcastMetadata.itunesId, 'itunes-id': podcast.itunesId,
'podcast-type': podcastMetadata.type, 'podcast-type': podcast.podcastType,
'episode-type': podcastMetadata.episodeType 'episode-type': podcastEpisode.episodeType
} }
for (const tag in taggings) { for (const tag in taggings) {