Update podcasts to new library item model

This commit is contained in:
advplyr 2025-01-04 12:41:09 -06:00
parent 4a398f6113
commit d8823c8b1c
14 changed files with 416 additions and 281 deletions

View File

@ -1,3 +1,4 @@
const Path = require('path')
const { Request, Response, NextFunction } = require('express') const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
@ -12,8 +13,6 @@ const { validateUrl } = require('../utils/index')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
const CoverManager = require('../managers/CoverManager') const CoverManager = require('../managers/CoverManager')
const LibraryItem = require('../objects/LibraryItem')
/** /**
* @typedef RequestUserObject * @typedef RequestUserObject
* @property {import('../models/User')} user * @property {import('../models/User')} user
@ -42,6 +41,9 @@ class PodcastController {
return res.sendStatus(403) return res.sendStatus(403)
} }
const payload = req.body const payload = req.body
if (!payload.media || !payload.media.metadata) {
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required')
}
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId) const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
if (!library) { if (!library) {
@ -83,43 +85,87 @@ class PodcastController {
let relPath = payload.path.replace(folder.fullPath, '') let relPath = payload.path.replace(folder.fullPath, '')
if (relPath.startsWith('/')) relPath = relPath.slice(1) if (relPath.startsWith('/')) relPath = relPath.slice(1)
const libraryItemPayload = { let newLibraryItem = null
const transaction = await Database.sequelize.transaction()
try {
const podcast = await Database.podcastModel.createFromRequest(payload.media, transaction)
newLibraryItem = await Database.libraryItemModel.create(
{
ino: libraryItemFolderStats.ino,
path: podcastPath, path: podcastPath,
relPath, relPath,
folderId: payload.folderId, mediaId: podcast.id,
libraryId: payload.libraryId, mediaType: 'podcast',
ino: libraryItemFolderStats.ino, isFile: false,
mtimeMs: libraryItemFolderStats.mtimeMs || 0, isMissing: false,
ctimeMs: libraryItemFolderStats.ctimeMs || 0, isInvalid: false,
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, mtime: libraryItemFolderStats.mtimeMs || 0,
media: payload.media ctime: libraryItemFolderStats.ctimeMs || 0,
birthtime: libraryItemFolderStats.birthtimeMs || 0,
size: 0,
libraryFiles: [],
extraData: {},
libraryId: library.id,
libraryFolderId: folder.id
},
{ transaction }
)
await transaction.commit()
} catch (error) {
Logger.error(`[PodcastController] Failed to create podcast: ${error}`)
await transaction.rollback()
return res.status(500).send('Failed to create podcast')
} }
const libraryItem = new LibraryItem() newLibraryItem.media = await newLibraryItem.getMediaExpanded()
libraryItem.setData('podcast', libraryItemPayload)
// Download and save cover image // Download and save cover image
if (payload.media.metadata.imageUrl) { if (typeof payload.media.metadata.imageUrl === 'string' && payload.media.metadata.imageUrl.startsWith('http')) {
// TODO: Scan cover image to library files
// Podcast cover will always go into library item folder // Podcast cover will always go into library item folder
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true) const coverResponse = await CoverManager.downloadCoverFromUrlNew(payload.media.metadata.imageUrl, newLibraryItem.id, newLibraryItem.path, true)
if (coverResponse) {
if (coverResponse.error) { if (coverResponse.error) {
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`) Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
} else if (coverResponse.cover) { } else if (coverResponse.cover) {
libraryItem.media.coverPath = coverResponse.cover const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
if (!coverImageFileStats) {
Logger.error(`[PodcastController] Failed to get cover image stats for "${coverResponse.cover}"`)
} else {
// Add libraryFile to libraryItem and coverPath to podcast
const newLibraryFile = {
ino: coverImageFileStats.ino,
fileType: 'image',
addedAt: Date.now(),
updatedAt: Date.now(),
metadata: {
filename: Path.basename(coverResponse.cover),
ext: Path.extname(coverResponse.cover).slice(1),
path: coverResponse.cover,
relPath: Path.basename(coverResponse.cover),
size: coverImageFileStats.size,
mtimeMs: coverImageFileStats.mtimeMs || 0,
ctimeMs: coverImageFileStats.ctimeMs || 0,
birthtimeMs: coverImageFileStats.birthtimeMs || 0
}
}
newLibraryItem.libraryFiles.push(newLibraryFile)
newLibraryItem.changed('libraryFiles', true)
await newLibraryItem.save()
newLibraryItem.media.coverPath = coverResponse.cover
await newLibraryItem.media.save()
} }
} }
} }
await Database.createLibraryItem(libraryItem) SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSONExpanded()) res.json(newLibraryItem.toOldJSONExpanded())
// 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 (newLibraryItem.media.autoDownloadEpisodes) {
this.cronManager.checkUpdatePodcastCron(libraryItem) this.cronManager.checkUpdatePodcastCron(newLibraryItem)
} }
} }

View File

@ -338,13 +338,14 @@ class CoverManager {
* *
* @param {string} url * @param {string} url
* @param {string} libraryItemId * @param {string} libraryItemId
* @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast * @param {string} [libraryItemPath] - null if library item isFile
* @param {boolean} [forceLibraryItemFolder=false] - force save cover with library item (used for adding new podcasts)
* @returns {Promise<{error:string}|{cover:string}>} * @returns {Promise<{error:string}|{cover:string}>}
*/ */
async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath) { async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath, forceLibraryItemFolder = false) {
try { try {
let coverDirPath = null let coverDirPath = null
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) { if ((global.ServerSettings.storeCoverWithItem || forceLibraryItemFolder) && libraryItemPath) {
coverDirPath = libraryItemPath coverDirPath = libraryItemPath
} else { } else {
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId) coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)

View File

@ -217,7 +217,7 @@ class CronManager {
/** /**
* *
* @param {import('../models/LibraryItem')} libraryItem - this can be the old model * @param {import('../models/LibraryItem')} libraryItem
*/ */
checkUpdatePodcastCron(libraryItem) { checkUpdatePodcastCron(libraryItem) {
// Remove from old cron by library item id // Remove from old cron by library item id

View File

@ -14,6 +14,11 @@ class NotificationManager {
return notificationData return notificationData
} }
/**
*
* @param {import('../models/LibraryItem')} libraryItem
* @param {import('../models/PodcastEpisode')} episode
*/
async onPodcastEpisodeDownloaded(libraryItem, episode) { async onPodcastEpisodeDownloaded(libraryItem, episode) {
if (!Database.notificationSettings.isUseable) return if (!Database.notificationSettings.isUseable) return
@ -22,17 +27,17 @@ class NotificationManager {
return return
} }
Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`) Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.title}`)
const library = await Database.libraryModel.findByPk(libraryItem.libraryId) const library = await Database.libraryModel.findByPk(libraryItem.libraryId)
const eventData = { const eventData = {
libraryItemId: libraryItem.id, libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId, libraryId: libraryItem.libraryId,
libraryName: library?.name || 'Unknown', libraryName: library?.name || 'Unknown',
mediaTags: (libraryItem.media.tags || []).join(', '), mediaTags: (libraryItem.media.tags || []).join(', '),
podcastTitle: libraryItem.media.metadata.title, podcastTitle: libraryItem.media.title,
podcastAuthor: libraryItem.media.metadata.author || '', podcastAuthor: libraryItem.media.author || '',
podcastDescription: libraryItem.media.metadata.description || '', podcastDescription: libraryItem.media.description || '',
podcastGenres: (libraryItem.media.metadata.genres || []).join(', '), podcastGenres: (libraryItem.media.genres || []).join(', '),
episodeId: episode.id, episodeId: episode.id,
episodeTitle: episode.title, episodeTitle: episode.title,
episodeSubtitle: episode.subtitle || '', episodeSubtitle: episode.subtitle || '',

View File

@ -1,3 +1,4 @@
const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
@ -19,9 +20,7 @@ const NotificationManager = require('../managers/NotificationManager')
const LibraryFile = require('../objects/files/LibraryFile') const LibraryFile = require('../objects/files/LibraryFile')
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload') const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
const AudioFile = require('../objects/files/AudioFile') const AudioFile = require('../objects/files/AudioFile')
const LibraryItem = require('../objects/LibraryItem')
class PodcastManager { class PodcastManager {
constructor() { constructor() {
@ -55,17 +54,13 @@ class PodcastManager {
/** /**
* *
* @param {import('../models/LibraryItem')} libraryItem * @param {import('../models/LibraryItem')} libraryItem
* @param {*} episodesToDownload * @param {import('../utils/podcastUtils').RssPodcastEpisode[]} episodesToDownload
* @param {*} isAutoDownload * @param {boolean} isAutoDownload - If this download was triggered by auto download
*/ */
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) { async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
for (const ep of episodesToDownload) { for (const ep of episodesToDownload) {
const newPe = new PodcastEpisode()
newPe.setData(ep, null)
newPe.libraryItemId = libraryItem.id
newPe.podcastId = libraryItem.media.id
const newPeDl = new PodcastEpisodeDownload() const newPeDl = new PodcastEpisodeDownload()
newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId) newPeDl.setData(ep, libraryItem, isAutoDownload, libraryItem.libraryId)
this.startPodcastEpisodeDownload(newPeDl) this.startPodcastEpisodeDownload(newPeDl)
} }
} }
@ -91,20 +86,20 @@ class PodcastManager {
key: 'MessageDownloadingEpisode' key: 'MessageDownloadingEpisode'
} }
const taskDescriptionString = { const taskDescriptionString = {
text: `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`, text: `Downloading episode "${podcastEpisodeDownload.episodeTitle}".`,
key: 'MessageTaskDownloadingEpisodeDescription', key: 'MessageTaskDownloadingEpisodeDescription',
subs: [podcastEpisodeDownload.podcastEpisode.title] subs: [podcastEpisodeDownload.episodeTitle]
} }
const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData) const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData)
SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient()) SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
this.currentDownload = podcastEpisodeDownload this.currentDownload = podcastEpisodeDownload
// If this file already exists then append the episode id to the filename // If this file already exists then append a uuid to the filename
// e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3" // e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3"
// this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802) // this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802)
if (await fs.pathExists(this.currentDownload.targetPath)) { if (await fs.pathExists(this.currentDownload.targetPath)) {
this.currentDownload.appendEpisodeId = true this.currentDownload.appendRandomId = true
} }
// Ignores all added files to this dir // Ignores all added files to this dir
@ -145,7 +140,7 @@ class PodcastManager {
} }
task.setFailed(taskFailedString) task.setFailed(taskFailedString)
} else { } else {
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`) Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.episodeTitle}"`)
this.currentDownload.setFinished(true) this.currentDownload.setFinished(true)
task.setFinished() task.setFinished()
} }
@ -171,47 +166,61 @@ class PodcastManager {
} }
} }
/**
* Scans the downloaded audio file, create the podcast episode, remove oldest episode if necessary
* @returns {Promise<boolean>} - Returns true if added
*/
async scanAddPodcastEpisodeAudioFile() { async scanAddPodcastEpisodeAudioFile() {
const libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath) const libraryFile = new LibraryFile()
await libraryFile.setDataFromPath(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
const audioFile = await this.probeAudioFile(libraryFile) const audioFile = await this.probeAudioFile(libraryFile)
if (!audioFile) { if (!audioFile) {
return false return false
} }
const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id) const libraryItem = await Database.libraryItemModel.getExpandedById(this.currentDownload.libraryItem.id)
if (!libraryItem) { if (!libraryItem) {
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`) Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
return false return false
} }
const podcastEpisode = this.currentDownload.podcastEpisode const podcastEpisode = await Database.podcastEpisodeModel.createFromRssPodcastEpisode(this.currentDownload.rssPodcastEpisode, libraryItem.media.id, audioFile)
podcastEpisode.audioFile = audioFile
if (audioFile.chapters?.length) { libraryItem.libraryFiles.push(libraryFile.toJSON())
podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch })) libraryItem.changed('libraryFiles', true)
}
libraryItem.media.addPodcastEpisode(podcastEpisode) libraryItem.media.podcastEpisodes.push(podcastEpisode)
if (libraryItem.isInvalid) {
// First episode added to an empty podcast
libraryItem.isInvalid = false
}
libraryItem.libraryFiles.push(libraryFile)
if (this.currentDownload.isAutoDownload) { if (this.currentDownload.isAutoDownload) {
// Check setting maxEpisodesToKeep and remove episode if necessary // Check setting maxEpisodesToKeep and remove episode if necessary
if (libraryItem.media.maxEpisodesToKeep && libraryItem.media.episodesWithPubDate.length > libraryItem.media.maxEpisodesToKeep) { const numEpisodesWithPubDate = libraryItem.media.podcastEpisodes.filter((ep) => !!ep.publishedAt).length
Logger.info(`[PodcastManager] # of episodes (${libraryItem.media.episodesWithPubDate.length}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`) if (libraryItem.media.maxEpisodesToKeep && numEpisodesWithPubDate > libraryItem.media.maxEpisodesToKeep) {
await this.removeOldestEpisode(libraryItem, podcastEpisode.id) Logger.info(`[PodcastManager] # of episodes (${numEpisodesWithPubDate}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`)
const episodeToRemove = await this.getRemoveOldestEpisode(libraryItem, podcastEpisode.id)
if (episodeToRemove) {
// Remove episode from playlists
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id])
// Remove media progress for this episode
await Database.mediaProgressModel.destroy({
where: {
mediaItemId: episodeToRemove.id
}
})
await episodeToRemove.destroy()
libraryItem.media.podcastEpisodes = libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeToRemove.id)
// Remove library file
libraryItem.libraryFiles = libraryItem.libraryFiles.filter((lf) => lf.ino !== episodeToRemove.audioFile.ino)
}
} }
} }
libraryItem.updatedAt = Date.now() await libraryItem.save()
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
const podcastEpisodeExpanded = podcastEpisode.toJSONExpanded() const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id)
podcastEpisodeExpanded.libraryItem = libraryItem.toJSONExpanded() podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded()
SocketAuthority.emitter('episode_added', podcastEpisodeExpanded) SocketAuthority.emitter('episode_added', podcastEpisodeExpanded)
if (this.currentDownload.isAutoDownload) { if (this.currentDownload.isAutoDownload) {
@ -222,45 +231,53 @@ class PodcastManager {
return true return true
} }
async removeOldestEpisode(libraryItem, episodeIdJustDownloaded) { /**
var smallestPublishedAt = 0 * Find oldest episode publishedAt and delete the audio file
var oldestEpisode = null *
libraryItem.media.episodesWithPubDate * @param {import('../models/LibraryItem').LibraryItemExpanded} libraryItem
.filter((ep) => ep.id !== episodeIdJustDownloaded) * @param {string} episodeIdJustDownloaded
.forEach((ep) => { * @returns {Promise<import('../models/PodcastEpisode')|null>} - Returns the episode to remove
*/
async getRemoveOldestEpisode(libraryItem, episodeIdJustDownloaded) {
let smallestPublishedAt = 0
/** @type {import('../models/PodcastEpisode')} */
let oldestEpisode = null
/** @type {import('../models/PodcastEpisode')[]} */
const podcastEpisodes = libraryItem.media.podcastEpisodes
for (const ep of podcastEpisodes) {
if (ep.id === episodeIdJustDownloaded || !ep.publishedAt) continue
if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) { if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) {
smallestPublishedAt = ep.publishedAt smallestPublishedAt = ep.publishedAt
oldestEpisode = ep oldestEpisode = ep
} }
}) }
// TODO: Should we check for open playback sessions for this episode?
// TODO: remove all user progress for this episode
if (oldestEpisode?.audioFile) { if (oldestEpisode?.audioFile) {
Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`) Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`)
const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path) const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path)
if (successfullyDeleted) { if (successfullyDeleted) {
libraryItem.media.removeEpisode(oldestEpisode.id) return oldestEpisode
libraryItem.removeLibraryFile(oldestEpisode.audioFile.ino)
return true
} else { } else {
Logger.warn(`[PodcastManager] Failed to remove oldest episode "${oldestEpisode.title}"`) Logger.warn(`[PodcastManager] Failed to remove oldest episode "${oldestEpisode.title}"`)
} }
} }
return false return null
}
async getLibraryFile(path, relPath) {
var newLibFile = new LibraryFile()
await newLibFile.setDataFromPath(path, relPath)
return newLibFile
} }
/**
*
* @param {LibraryFile} libraryFile
* @returns {Promise<AudioFile|null>}
*/
async probeAudioFile(libraryFile) { async probeAudioFile(libraryFile) {
const path = libraryFile.metadata.path const path = libraryFile.metadata.path
const mediaProbeData = await prober.probe(path) const mediaProbeData = await prober.probe(path)
if (mediaProbeData.error) { if (mediaProbeData.error) {
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error) Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
return false return null
} }
const newAudioFile = new AudioFile() const newAudioFile = new AudioFile()
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData) newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
@ -284,7 +301,7 @@ class PodcastManager {
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.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) const 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`)
if (!newEpisodes) { if (!newEpisodes) {
@ -324,17 +341,17 @@ class PodcastManager {
* @param {import('../models/LibraryItem')} podcastLibraryItem * @param {import('../models/LibraryItem')} podcastLibraryItem
* @param {number} dateToCheckForEpisodesAfter - Unix timestamp * @param {number} dateToCheckForEpisodesAfter - Unix timestamp
* @param {number} maxNewEpisodes * @param {number} maxNewEpisodes
* @returns * @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]|null>}
*/ */
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) { async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) {
if (!podcastLibraryItem.media.feedURL) { if (!podcastLibraryItem.media.feedURL) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`) Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`)
return false return null
} }
const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL) const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL)
if (!feed?.episodes) { if (!feed?.episodes) {
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed) Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed)
return false return null
} }
// Filter new and not already has // Filter new and not already has
@ -351,15 +368,15 @@ class PodcastManager {
* *
* @param {import('../models/LibraryItem')} libraryItem * @param {import('../models/LibraryItem')} libraryItem
* @param {*} maxEpisodesToDownload * @param {*} maxEpisodesToDownload
* @returns * @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]>}
*/ */
async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) { async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) {
const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0 const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem.media.lastEpisodeCheck : 'Never' const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem.media.lastEpisodeCheck : 'Never'
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.title}" - Last episode check: ${lastEpisodeCheckDate}`) Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.title}" - Last episode check: ${lastEpisodeCheckDate}`)
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, lastEpisodeCheck, maxEpisodesToDownload) const 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.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 {
@ -374,7 +391,7 @@ class PodcastManager {
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
return newEpisodes return newEpisodes || []
} }
async findEpisode(rssFeedUrl, searchTitle) { async findEpisode(rssFeedUrl, searchTitle) {
@ -550,7 +567,14 @@ class PodcastManager {
continue continue
} }
const newPodcastMetadata = { let newLibraryItem = null
const transaction = await Database.sequelize.transaction()
try {
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
const podcastPayload = {
autoDownloadEpisodes,
metadata: {
title: feed.metadata.title, title: feed.metadata.title,
author: feed.metadata.author, author: feed.metadata.author,
description: feed.metadata.description, description: feed.metadata.description,
@ -564,50 +588,102 @@ class PodcastManager {
language: '', language: '',
numEpisodes: feed.numEpisodes numEpisodes: feed.numEpisodes
} }
}
const podcast = await Database.podcastModel.createFromRequest(podcastPayload, transaction)
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath) newLibraryItem = await Database.libraryItemModel.create(
const libraryItemPayload = { {
ino: libraryItemFolderStats.ino,
path: podcastPath, path: podcastPath,
relPath: podcastFilename, relPath: podcastFilename,
folderId: folder.id, mediaId: podcast.id,
mediaType: 'podcast',
isFile: false,
isMissing: false,
isInvalid: false,
mtime: libraryItemFolderStats.mtimeMs || 0,
ctime: libraryItemFolderStats.ctimeMs || 0,
birthtime: libraryItemFolderStats.birthtimeMs || 0,
size: 0,
libraryFiles: [],
extraData: {},
libraryId: folder.libraryId, libraryId: folder.libraryId,
ino: libraryItemFolderStats.ino, libraryFolderId: folder.id
mtimeMs: libraryItemFolderStats.mtimeMs || 0, },
ctimeMs: libraryItemFolderStats.ctimeMs || 0, { transaction }
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, )
media: {
metadata: newPodcastMetadata, await transaction.commit()
autoDownloadEpisodes } catch (error) {
await transaction.rollback()
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast library item for "${feed.metadata.title}"`, error)
const taskTitleStringFeed = {
text: 'OPML import feed',
key: 'MessageTaskOpmlImportFeed'
} }
const taskDescriptionStringPodcast = {
text: `Creating podcast "${feed.metadata.title}"`,
key: 'MessageTaskOpmlImportFeedPodcastDescription',
subs: [feed.metadata.title]
}
const taskErrorString = {
text: 'Failed to create podcast library item',
key: 'MessageTaskOpmlImportFeedPodcastFailed'
}
TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString)
continue
} }
const libraryItem = new LibraryItem() newLibraryItem.media = await newLibraryItem.getMediaExpanded()
libraryItem.setData('podcast', libraryItemPayload)
// Download and save cover image // Download and save cover image
if (newPodcastMetadata.imageUrl) { if (typeof feed.metadata.image === 'string' && feed.metadata.image.startsWith('http')) {
// TODO: Scan cover image to library files
// Podcast cover will always go into library item folder // Podcast cover will always go into library item folder
const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, newPodcastMetadata.imageUrl, true) const coverResponse = await CoverManager.downloadCoverFromUrlNew(feed.metadata.image, newLibraryItem.id, newLibraryItem.path, true)
if (coverResponse) {
if (coverResponse.error) { if (coverResponse.error) {
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Download cover error from "${newPodcastMetadata.imageUrl}": ${coverResponse.error}`) Logger.error(`[PodcastManager] Download cover error from "${feed.metadata.image}": ${coverResponse.error}`)
} else if (coverResponse.cover) { } else if (coverResponse.cover) {
libraryItem.media.coverPath = coverResponse.cover const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
if (!coverImageFileStats) {
Logger.error(`[PodcastManager] Failed to get cover image stats for "${coverResponse.cover}"`)
} else {
// Add libraryFile to libraryItem and coverPath to podcast
const newLibraryFile = {
ino: coverImageFileStats.ino,
fileType: 'image',
addedAt: Date.now(),
updatedAt: Date.now(),
metadata: {
filename: Path.basename(coverResponse.cover),
ext: Path.extname(coverResponse.cover).slice(1),
path: coverResponse.cover,
relPath: Path.basename(coverResponse.cover),
size: coverImageFileStats.size,
mtimeMs: coverImageFileStats.mtimeMs || 0,
ctimeMs: coverImageFileStats.ctimeMs || 0,
birthtimeMs: coverImageFileStats.birthtimeMs || 0
}
}
newLibraryItem.libraryFiles.push(newLibraryFile)
newLibraryItem.changed('libraryFiles', true)
await newLibraryItem.save()
newLibraryItem.media.coverPath = coverResponse.cover
await newLibraryItem.media.save()
} }
} }
} }
await Database.createLibraryItem(libraryItem) SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded())
SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded())
// 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 (newLibraryItem.media.autoDownloadEpisodes) {
cronManager.checkUpdatePodcastCron(libraryItem) cronManager.checkUpdatePodcastCron(newLibraryItem)
} }
numPodcastsAdded++ numPodcastsAdded++
} }
const taskFinishedString = { const taskFinishedString = {
text: `Added ${numPodcastsAdded} podcasts`, text: `Added ${numPodcastsAdded} podcasts`,
key: 'MessageTaskOpmlImportFinished', key: 'MessageTaskOpmlImportFinished',

View File

@ -126,6 +126,45 @@ class Podcast extends Model {
} }
} }
/**
* Payload from the /api/podcasts POST endpoint
*
* @param {Object} payload
* @param {import('sequelize').Transaction} transaction
*/
static async createFromRequest(payload, transaction) {
const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null
const autoDownloadSchedule = typeof payload.autoDownloadSchedule === 'string' ? payload.autoDownloadSchedule : null
const genres = Array.isArray(payload.metadata.genres) && payload.metadata.genres.every((g) => typeof g === 'string' && g.length) ? payload.metadata.genres : []
const tags = Array.isArray(payload.tags) && payload.tags.every((t) => typeof t === 'string' && t.length) ? payload.tags : []
return this.create(
{
title,
titleIgnorePrefix: getTitleIgnorePrefix(title),
author: typeof payload.metadata.author === 'string' ? payload.metadata.author : null,
releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null,
feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null,
imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : null,
description: typeof payload.metadata.description === 'string' ? payload.metadata.description : null,
itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null,
itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null,
itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null,
language: typeof payload.metadata.language === 'string' ? payload.metadata.language : null,
podcastType: typeof payload.metadata.type === 'string' ? payload.metadata.type : null,
explicit: !!payload.metadata.explicit,
autoDownloadEpisodes: !!payload.autoDownloadEpisodes,
autoDownloadSchedule: autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule,
lastEpisodeCheck: new Date(),
maxEpisodesToKeep: 0,
maxNewEpisodesToDownload: 3,
tags,
genres
},
{ transaction }
)
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
@ -368,7 +407,7 @@ class Podcast extends Model {
/** /**
* Used for checking if an rss feed episode is already in the podcast * Used for checking if an rss feed episode is already in the podcast
* *
* @param {Object} feedEpisode - object from rss feed * @param {import('../utils/podcastUtils').RssPodcastEpisode} feedEpisode - object from rss feed
* @returns {boolean} * @returns {boolean}
*/ */
checkHasEpisodeByFeedEpisode(feedEpisode) { checkHasEpisodeByFeedEpisode(feedEpisode) {

View File

@ -87,6 +87,40 @@ class PodcastEpisode extends Model {
} }
} }
/**
*
* @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode
* @param {string} podcastId
* @param {import('../objects/files/AudioFile')} audioFile
*/
static async createFromRssPodcastEpisode(rssPodcastEpisode, podcastId, audioFile) {
const podcastEpisode = {
index: null,
season: rssPodcastEpisode.season,
episode: rssPodcastEpisode.episode,
episodeType: rssPodcastEpisode.episodeType,
title: rssPodcastEpisode.title,
subtitle: rssPodcastEpisode.subtitle,
description: rssPodcastEpisode.description,
pubDate: rssPodcastEpisode.pubDate,
enclosureURL: rssPodcastEpisode.enclosure?.url || null,
enclosureSize: rssPodcastEpisode.enclosure?.length || null,
enclosureType: rssPodcastEpisode.enclosure?.type || null,
publishedAt: rssPodcastEpisode.publishedAt,
podcastId,
audioFile: audioFile.toJSON(),
chapters: [],
extraData: {}
}
if (rssPodcastEpisode.guid) {
podcastEpisode.extraData.guid = rssPodcastEpisode.guid
}
if (audioFile.chapters?.length) {
podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch }))
}
return this.create(podcastEpisode)
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize

View File

@ -1,4 +1,3 @@
const uuidv4 = require('uuid').v4
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const Path = require('path') const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
@ -178,45 +177,6 @@ class LibraryItem {
return this.libraryFiles.some((lf) => lf.fileType === 'audio') return this.libraryFiles.some((lf) => lf.fileType === 'audio')
} }
// Data comes from scandir library item data
// TODO: Remove this function. Only used when creating a new podcast now
setData(libraryMediaType, payload) {
this.id = uuidv4()
this.mediaType = libraryMediaType
if (libraryMediaType === 'podcast') {
this.media = new Podcast()
} else {
Logger.error(`[LibraryItem] setData called with unsupported media type "${libraryMediaType}"`)
return
}
this.media.id = uuidv4()
this.media.libraryItemId = this.id
for (const key in payload) {
if (key === 'libraryFiles') {
this.libraryFiles = payload.libraryFiles.map((lf) => lf.clone())
// Set cover image
const imageFiles = this.libraryFiles.filter((lf) => lf.fileType === 'image')
const coverMatch = imageFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
if (coverMatch) {
this.media.coverPath = coverMatch.metadata.path
} else if (imageFiles.length) {
this.media.coverPath = imageFiles[0].metadata.path
}
} else if (this[key] !== undefined && key !== 'media') {
this[key] = payload[key]
}
}
if (payload.media) {
this.media.setData(payload.media)
}
this.addedAt = Date.now()
this.updatedAt = Date.now()
}
update(payload) { update(payload) {
const json = this.toJSON() const json = this.toJSON()
let hasUpdates = false let hasUpdates = false

View File

@ -6,8 +6,9 @@ const globals = require('../utils/globals')
class PodcastEpisodeDownload { class PodcastEpisodeDownload {
constructor() { constructor() {
this.id = null this.id = null
/** @type {import('../objects/entities/PodcastEpisode')} */ /** @type {import('../utils/podcastUtils').RssPodcastEpisode} */
this.podcastEpisode = null this.rssPodcastEpisode = null
this.url = null this.url = null
/** @type {import('../models/LibraryItem')} */ /** @type {import('../models/LibraryItem')} */
this.libraryItem = null this.libraryItem = null
@ -17,7 +18,7 @@ class PodcastEpisodeDownload {
this.isFinished = false this.isFinished = false
this.failed = false this.failed = false
this.appendEpisodeId = false this.appendRandomId = false
this.startedAt = null this.startedAt = null
this.createdAt = null this.createdAt = null
@ -27,22 +28,22 @@ class PodcastEpisodeDownload {
toJSONForClient() { toJSONForClient() {
return { return {
id: this.id, id: this.id,
episodeDisplayTitle: this.podcastEpisode?.title ?? null, episodeDisplayTitle: this.rssPodcastEpisode?.title ?? null,
url: this.url, url: this.url,
libraryItemId: this.libraryItemId, libraryItemId: this.libraryItemId,
libraryId: this.libraryId || null, libraryId: this.libraryId || null,
isFinished: this.isFinished, isFinished: this.isFinished,
failed: this.failed, failed: this.failed,
appendEpisodeId: this.appendEpisodeId, appendRandomId: this.appendRandomId,
startedAt: this.startedAt, startedAt: this.startedAt,
createdAt: this.createdAt, createdAt: this.createdAt,
finishedAt: this.finishedAt, finishedAt: this.finishedAt,
podcastTitle: this.libraryItem?.media.title ?? null, podcastTitle: this.libraryItem?.media.title ?? null,
podcastExplicit: !!this.libraryItem?.media.explicit, podcastExplicit: !!this.libraryItem?.media.explicit,
season: this.podcastEpisode?.season ?? null, season: this.rssPodcastEpisode?.season ?? null,
episode: this.podcastEpisode?.episode ?? null, episode: this.rssPodcastEpisode?.episode ?? null,
episodeType: this.podcastEpisode?.episodeType ?? 'full', episodeType: this.rssPodcastEpisode?.episodeType ?? 'full',
publishedAt: this.podcastEpisode?.publishedAt ?? null publishedAt: this.rssPodcastEpisode?.publishedAt ?? null
} }
} }
@ -56,7 +57,7 @@ class PodcastEpisodeDownload {
return 'mp3' return 'mp3'
} }
get enclosureType() { get enclosureType() {
const enclosureType = this.podcastEpisode?.enclosure?.type const enclosureType = this.rssPodcastEpisode.enclosure.type
return typeof enclosureType === 'string' ? enclosureType : null return typeof enclosureType === 'string' ? enclosureType : null
} }
/** /**
@ -69,10 +70,12 @@ class PodcastEpisodeDownload {
if (this.enclosureType && !this.enclosureType.includes('mpeg')) return false if (this.enclosureType && !this.enclosureType.includes('mpeg')) return false
return this.fileExtension === 'mp3' return this.fileExtension === 'mp3'
} }
get episodeTitle() {
return this.rssPodcastEpisode.title
}
get targetFilename() { get targetFilename() {
const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : '' const appendage = this.appendRandomId ? ` (${uuidv4()})` : ''
const filename = `${this.podcastEpisode.title}${appendage}.${this.fileExtension}` const filename = `${this.rssPodcastEpisode.title}${appendage}.${this.fileExtension}`
return sanitizeFilename(filename) return sanitizeFilename(filename)
} }
get targetPath() { get targetPath() {
@ -84,19 +87,23 @@ class PodcastEpisodeDownload {
get libraryItemId() { get libraryItemId() {
return this.libraryItem?.id || null return this.libraryItem?.id || null
} }
get pubYear() {
if (!this.rssPodcastEpisode.publishedAt) return null
return new Date(this.rssPodcastEpisode.publishedAt).getFullYear()
}
/** /**
* *
* @param {import('../objects/entities/PodcastEpisode')} podcastEpisode - old model * @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode - from rss feed
* @param {import('../models/LibraryItem')} libraryItem * @param {import('../models/LibraryItem')} libraryItem
* @param {*} isAutoDownload * @param {*} isAutoDownload
* @param {*} libraryId * @param {*} libraryId
*/ */
setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) { setData(rssPodcastEpisode, libraryItem, isAutoDownload, libraryId) {
this.id = uuidv4() this.id = uuidv4()
this.podcastEpisode = podcastEpisode this.rssPodcastEpisode = rssPodcastEpisode
const url = podcastEpisode.enclosure.url const url = rssPodcastEpisode.enclosure.url
if (decodeURIComponent(url) !== url) { if (decodeURIComponent(url) !== url) {
// Already encoded // Already encoded
this.url = url this.url = url

View File

@ -1,4 +1,3 @@
const uuidv4 = require('uuid').v4
const { areEquivalent, copyValue } = require('../../utils/index') const { areEquivalent, copyValue } = require('../../utils/index')
const AudioFile = require('../files/AudioFile') const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack') const AudioTrack = require('../files/AudioTrack')
@ -127,27 +126,6 @@ class PodcastEpisode {
get enclosureUrl() { get enclosureUrl() {
return this.enclosure?.url || null return this.enclosure?.url || null
} }
get pubYear() {
if (!this.publishedAt) return null
return new Date(this.publishedAt).getFullYear()
}
setData(data, index = 1) {
this.id = uuidv4()
this.index = index
this.title = data.title
this.subtitle = data.subtitle || ''
this.pubDate = data.pubDate || ''
this.description = data.description || ''
this.enclosure = data.enclosure ? { ...data.enclosure } : null
this.guid = data.guid || null
this.season = data.season || ''
this.episode = data.episode || ''
this.episodeType = data.episodeType || 'full'
this.publishedAt = data.publishedAt || 0
this.addedAt = Date.now()
this.updatedAt = Date.now()
}
update(payload) { update(payload) {
let hasUpdates = false let hasUpdates = false

View File

@ -132,18 +132,6 @@ class Podcast {
get numTracks() { get numTracks() {
return this.episodes.length return this.episodes.length
} }
get latestEpisodePublished() {
var largestPublishedAt = 0
this.episodes.forEach((ep) => {
if (ep.publishedAt && ep.publishedAt > largestPublishedAt) {
largestPublishedAt = ep.publishedAt
}
})
return largestPublishedAt
}
get episodesWithPubDate() {
return this.episodes.filter((ep) => !!ep.publishedAt)
}
update(payload) { update(payload) {
var json = this.toJSON() var json = this.toJSON()
@ -178,34 +166,10 @@ class Podcast {
return true return true
} }
setData(mediaData) {
this.metadata = new PodcastMetadata()
if (mediaData.metadata) {
this.metadata.setData(mediaData.metadata)
}
this.coverPath = mediaData.coverPath || null
this.autoDownloadEpisodes = !!mediaData.autoDownloadEpisodes
this.autoDownloadSchedule = mediaData.autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule
this.lastEpisodeCheck = Date.now() // Makes sure new episodes are after this
}
checkHasEpisode(episodeId) { checkHasEpisode(episodeId) {
return this.episodes.some((ep) => ep.id === episodeId) return this.episodes.some((ep) => ep.id === episodeId)
} }
addPodcastEpisode(podcastEpisode) {
this.episodes.push(podcastEpisode)
}
removeEpisode(episodeId) {
const episode = this.episodes.find((ep) => ep.id === episodeId)
if (episode) {
this.episodes = this.episodes.filter((ep) => ep.id !== episodeId)
}
return episode
}
getEpisode(episodeId) { getEpisode(episodeId) {
if (!episodeId) return null if (!episodeId) return null

View File

@ -91,24 +91,6 @@ class PodcastMetadata {
return getTitlePrefixAtEnd(this.title) return getTitlePrefixAtEnd(this.title)
} }
setData(mediaMetadata = {}) {
this.title = mediaMetadata.title || null
this.author = mediaMetadata.author || null
this.description = mediaMetadata.description || null
this.releaseDate = mediaMetadata.releaseDate || null
this.feedUrl = mediaMetadata.feedUrl || null
this.imageUrl = mediaMetadata.imageUrl || null
this.itunesPageUrl = mediaMetadata.itunesPageUrl || null
this.itunesId = mediaMetadata.itunesId || null
this.itunesArtistId = mediaMetadata.itunesArtistId || null
this.explicit = !!mediaMetadata.explicit
this.language = mediaMetadata.language || null
this.type = mediaMetadata.type || null
if (mediaMetadata.genres && mediaMetadata.genres.length) {
this.genres = [...mediaMetadata.genres]
}
}
update(payload) { update(payload) {
const json = this.toJSON() const json = this.toJSON()
let hasUpdates = false let hasUpdates = false

View File

@ -125,7 +125,7 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
/** @type {import('../models/Podcast')} */ /** @type {import('../models/Podcast')} */
const podcast = podcastEpisodeDownload.libraryItem.media const podcast = podcastEpisodeDownload.libraryItem.media
const podcastEpisode = podcastEpisodeDownload.podcastEpisode const podcastEpisode = podcastEpisodeDownload.rssPodcastEpisode
const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0) const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0)
const taggings = { const taggings = {
@ -144,7 +144,7 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
'series-part': podcastEpisode.episode, 'series-part': podcastEpisode.episode,
title: podcastEpisode.title, title: podcastEpisode.title,
'title-sort': podcastEpisode.title, 'title-sort': podcastEpisode.title,
year: podcastEpisode.pubYear, year: podcastEpisodeDownload.pubYear,
date: podcastEpisode.pubDate, date: podcastEpisode.pubDate,
releasedate: podcastEpisode.pubDate, releasedate: podcastEpisode.pubDate,
'itunes-id': podcast.itunesId, 'itunes-id': podcast.itunesId,

View File

@ -4,6 +4,49 @@ const Logger = require('../Logger')
const { xmlToJSON, levenshteinDistance } = require('./index') const { xmlToJSON, levenshteinDistance } = require('./index')
const htmlSanitizer = require('../utils/htmlSanitizer') const htmlSanitizer = require('../utils/htmlSanitizer')
/**
* @typedef RssPodcastEpisode
* @property {string} title
* @property {string} subtitle
* @property {string} description
* @property {string} descriptionPlain
* @property {string} pubDate
* @property {string} episodeType
* @property {string} season
* @property {string} episode
* @property {string} author
* @property {string} duration
* @property {string} explicit
* @property {number} publishedAt - Unix timestamp
* @property {{ url: string, type?: string, length?: string }} enclosure
* @property {string} guid
* @property {string} chaptersUrl
* @property {string} chaptersType
*/
/**
* @typedef RssPodcastMetadata
* @property {string} title
* @property {string} language
* @property {string} explicit
* @property {string} author
* @property {string} pubDate
* @property {string} link
* @property {string} image
* @property {string[]} categories
* @property {string} feedUrl
* @property {string} description
* @property {string} descriptionPlain
* @property {string} type
*/
/**
* @typedef RssPodcast
* @property {RssPodcastMetadata} metadata
* @property {RssPodcastEpisode[]} episodes
* @property {number} numEpisodes
*/
function extractFirstArrayItem(json, key) { function extractFirstArrayItem(json, key) {
if (!json[key]?.length) return null if (!json[key]?.length) return null
return json[key][0] return json[key][0]
@ -223,7 +266,7 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal
* *
* @param {string} feedUrl * @param {string} feedUrl
* @param {boolean} [excludeEpisodeMetadata=false] * @param {boolean} [excludeEpisodeMetadata=false]
* @returns {Promise} * @returns {Promise<RssPodcast|null>}
*/ */
module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`) Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`)