diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index c62742a5..3d8ff240 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -1,3 +1,4 @@ +const Path = require('path') const { Request, Response, NextFunction } = require('express') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') @@ -12,8 +13,6 @@ const { validateUrl } = require('../utils/index') const Scanner = require('../scanner/Scanner') const CoverManager = require('../managers/CoverManager') -const LibraryItem = require('../objects/LibraryItem') - /** * @typedef RequestUserObject * @property {import('../models/User')} user @@ -42,6 +41,9 @@ class PodcastController { return res.sendStatus(403) } 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) if (!library) { @@ -83,43 +85,87 @@ class PodcastController { let relPath = payload.path.replace(folder.fullPath, '') if (relPath.startsWith('/')) relPath = relPath.slice(1) - const libraryItemPayload = { - path: podcastPath, - relPath, - folderId: payload.folderId, - libraryId: payload.libraryId, - ino: libraryItemFolderStats.ino, - mtimeMs: libraryItemFolderStats.mtimeMs || 0, - ctimeMs: libraryItemFolderStats.ctimeMs || 0, - birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, - media: payload.media + 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, + relPath, + 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: 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() - libraryItem.setData('podcast', libraryItemPayload) + newLibraryItem.media = await newLibraryItem.getMediaExpanded() // Download and save cover image - if (payload.media.metadata.imageUrl) { - // TODO: Scan cover image to library files + if (typeof payload.media.metadata.imageUrl === 'string' && payload.media.metadata.imageUrl.startsWith('http')) { // Podcast cover will always go into library item folder - const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true) - if (coverResponse) { - if (coverResponse.error) { - Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`) - } else if (coverResponse.cover) { - libraryItem.media.coverPath = coverResponse.cover + const coverResponse = await CoverManager.downloadCoverFromUrlNew(payload.media.metadata.imageUrl, newLibraryItem.id, newLibraryItem.path, true) + if (coverResponse.error) { + Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`) + } else if (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', libraryItem.toJSONExpanded()) + SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded()) - res.json(libraryItem.toJSONExpanded()) + res.json(newLibraryItem.toOldJSONExpanded()) // Turn on podcast auto download cron if not already on - if (libraryItem.media.autoDownloadEpisodes) { - this.cronManager.checkUpdatePodcastCron(libraryItem) + if (newLibraryItem.media.autoDownloadEpisodes) { + this.cronManager.checkUpdatePodcastCron(newLibraryItem) } } diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index c995a446..945c69ab 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -338,13 +338,14 @@ class CoverManager { * * @param {string} url * @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}>} */ - async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath) { + async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath, forceLibraryItemFolder = false) { try { let coverDirPath = null - if (global.ServerSettings.storeCoverWithItem && libraryItemPath) { + if ((global.ServerSettings.storeCoverWithItem || forceLibraryItemFolder) && libraryItemPath) { coverDirPath = libraryItemPath } else { coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId) diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index c61fb049..3f948583 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -217,7 +217,7 @@ class CronManager { /** * - * @param {import('../models/LibraryItem')} libraryItem - this can be the old model + * @param {import('../models/LibraryItem')} libraryItem */ checkUpdatePodcastCron(libraryItem) { // Remove from old cron by library item id diff --git a/server/managers/NotificationManager.js b/server/managers/NotificationManager.js index c48e878c..8edcf428 100644 --- a/server/managers/NotificationManager.js +++ b/server/managers/NotificationManager.js @@ -14,6 +14,11 @@ class NotificationManager { return notificationData } + /** + * + * @param {import('../models/LibraryItem')} libraryItem + * @param {import('../models/PodcastEpisode')} episode + */ async onPodcastEpisodeDownloaded(libraryItem, episode) { if (!Database.notificationSettings.isUseable) return @@ -22,17 +27,17 @@ class NotificationManager { 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 eventData = { libraryItemId: libraryItem.id, libraryId: libraryItem.libraryId, libraryName: library?.name || 'Unknown', mediaTags: (libraryItem.media.tags || []).join(', '), - podcastTitle: libraryItem.media.metadata.title, - podcastAuthor: libraryItem.media.metadata.author || '', - podcastDescription: libraryItem.media.metadata.description || '', - podcastGenres: (libraryItem.media.metadata.genres || []).join(', '), + podcastTitle: libraryItem.media.title, + podcastAuthor: libraryItem.media.author || '', + podcastDescription: libraryItem.media.description || '', + podcastGenres: (libraryItem.media.genres || []).join(', '), episodeId: episode.id, episodeTitle: episode.title, episodeSubtitle: episode.subtitle || '', diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 92053707..bd42e74b 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -1,3 +1,4 @@ +const Path = require('path') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') @@ -19,9 +20,7 @@ const NotificationManager = require('../managers/NotificationManager') const LibraryFile = require('../objects/files/LibraryFile') const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload') -const PodcastEpisode = require('../objects/entities/PodcastEpisode') const AudioFile = require('../objects/files/AudioFile') -const LibraryItem = require('../objects/LibraryItem') class PodcastManager { constructor() { @@ -55,17 +54,13 @@ class PodcastManager { /** * * @param {import('../models/LibraryItem')} libraryItem - * @param {*} episodesToDownload - * @param {*} isAutoDownload + * @param {import('../utils/podcastUtils').RssPodcastEpisode[]} episodesToDownload + * @param {boolean} isAutoDownload - If this download was triggered by auto download */ async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) { 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() - newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId) + newPeDl.setData(ep, libraryItem, isAutoDownload, libraryItem.libraryId) this.startPodcastEpisodeDownload(newPeDl) } } @@ -91,20 +86,20 @@ class PodcastManager { key: 'MessageDownloadingEpisode' } const taskDescriptionString = { - text: `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`, + text: `Downloading episode "${podcastEpisodeDownload.episodeTitle}".`, key: 'MessageTaskDownloadingEpisodeDescription', - subs: [podcastEpisodeDownload.podcastEpisode.title] + subs: [podcastEpisodeDownload.episodeTitle] } const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData) SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient()) 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" // this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802) if (await fs.pathExists(this.currentDownload.targetPath)) { - this.currentDownload.appendEpisodeId = true + this.currentDownload.appendRandomId = true } // Ignores all added files to this dir @@ -145,7 +140,7 @@ class PodcastManager { } task.setFailed(taskFailedString) } 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) task.setFinished() } @@ -171,47 +166,61 @@ class PodcastManager { } } + /** + * Scans the downloaded audio file, create the podcast episode, remove oldest episode if necessary + * @returns {Promise} - Returns true if added + */ 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) if (!audioFile) { return false } - const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id) + const libraryItem = await Database.libraryItemModel.getExpandedById(this.currentDownload.libraryItem.id) if (!libraryItem) { Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`) return false } - const podcastEpisode = this.currentDownload.podcastEpisode - podcastEpisode.audioFile = audioFile + const podcastEpisode = await Database.podcastEpisodeModel.createFromRssPodcastEpisode(this.currentDownload.rssPodcastEpisode, libraryItem.media.id, audioFile) - if (audioFile.chapters?.length) { - podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch })) - } + libraryItem.libraryFiles.push(libraryFile.toJSON()) + libraryItem.changed('libraryFiles', true) - libraryItem.media.addPodcastEpisode(podcastEpisode) - if (libraryItem.isInvalid) { - // First episode added to an empty podcast - libraryItem.isInvalid = false - } - libraryItem.libraryFiles.push(libraryFile) + libraryItem.media.podcastEpisodes.push(podcastEpisode) if (this.currentDownload.isAutoDownload) { // Check setting maxEpisodesToKeep and remove episode if necessary - if (libraryItem.media.maxEpisodesToKeep && libraryItem.media.episodesWithPubDate.length > libraryItem.media.maxEpisodesToKeep) { - Logger.info(`[PodcastManager] # of episodes (${libraryItem.media.episodesWithPubDate.length}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`) - await this.removeOldestEpisode(libraryItem, podcastEpisode.id) + const numEpisodesWithPubDate = libraryItem.media.podcastEpisodes.filter((ep) => !!ep.publishedAt).length + if (libraryItem.media.maxEpisodesToKeep && numEpisodesWithPubDate > libraryItem.media.maxEpisodesToKeep) { + 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 Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) - const podcastEpisodeExpanded = podcastEpisode.toJSONExpanded() - podcastEpisodeExpanded.libraryItem = libraryItem.toJSONExpanded() + await libraryItem.save() + + SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) + const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id) + podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded() SocketAuthority.emitter('episode_added', podcastEpisodeExpanded) if (this.currentDownload.isAutoDownload) { @@ -222,45 +231,53 @@ class PodcastManager { return true } - async removeOldestEpisode(libraryItem, episodeIdJustDownloaded) { - var smallestPublishedAt = 0 - var oldestEpisode = null - libraryItem.media.episodesWithPubDate - .filter((ep) => ep.id !== episodeIdJustDownloaded) - .forEach((ep) => { - if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) { - smallestPublishedAt = ep.publishedAt - oldestEpisode = ep - } - }) - // TODO: Should we check for open playback sessions for this episode? - // TODO: remove all user progress for this episode + /** + * Find oldest episode publishedAt and delete the audio file + * + * @param {import('../models/LibraryItem').LibraryItemExpanded} libraryItem + * @param {string} episodeIdJustDownloaded + * @returns {Promise} - 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) { + smallestPublishedAt = ep.publishedAt + oldestEpisode = ep + } + } + if (oldestEpisode?.audioFile) { Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`) const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path) if (successfullyDeleted) { - libraryItem.media.removeEpisode(oldestEpisode.id) - libraryItem.removeLibraryFile(oldestEpisode.audioFile.ino) - return true + return oldestEpisode } else { Logger.warn(`[PodcastManager] Failed to remove oldest episode "${oldestEpisode.title}"`) } } - return false - } - - async getLibraryFile(path, relPath) { - var newLibFile = new LibraryFile() - await newLibFile.setDataFromPath(path, relPath) - return newLibFile + return null } + /** + * + * @param {LibraryFile} libraryFile + * @returns {Promise} + */ async probeAudioFile(libraryFile) { const path = libraryFile.metadata.path const mediaProbeData = await prober.probe(path) if (mediaProbeData.error) { Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error) - return false + return null } const newAudioFile = new AudioFile() newAudioFile.setDataFromProbe(libraryFile, mediaProbeData) @@ -284,7 +301,7 @@ class PodcastManager { const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate 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`) if (!newEpisodes) { @@ -324,17 +341,17 @@ class PodcastManager { * @param {import('../models/LibraryItem')} podcastLibraryItem * @param {number} dateToCheckForEpisodesAfter - Unix timestamp * @param {number} maxNewEpisodes - * @returns + * @returns {Promise} */ async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) { if (!podcastLibraryItem.media.feedURL) { 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) if (!feed?.episodes) { 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 @@ -351,15 +368,15 @@ class PodcastManager { * * @param {import('../models/LibraryItem')} libraryItem * @param {*} maxEpisodesToDownload - * @returns + * @returns {Promise} */ async checkAndDownloadNewEpisodes(libraryItem, 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) { + const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, lastEpisodeCheck, maxEpisodesToDownload) + if (newEpisodes?.length) { Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`) this.downloadPodcastEpisodes(libraryItem, newEpisodes, false) } else { @@ -374,7 +391,7 @@ class PodcastManager { SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) - return newEpisodes + return newEpisodes || [] } async findEpisode(rssFeedUrl, searchTitle) { @@ -550,64 +567,123 @@ class PodcastManager { continue } - const newPodcastMetadata = { - title: feed.metadata.title, - author: feed.metadata.author, - description: feed.metadata.description, - releaseDate: '', - genres: [...feed.metadata.categories], - feedUrl: feed.metadata.feedUrl, - imageUrl: feed.metadata.image, - itunesPageUrl: '', - itunesId: '', - itunesArtistId: '', - language: '', - numEpisodes: feed.numEpisodes - } + let newLibraryItem = null + const transaction = await Database.sequelize.transaction() + try { + const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath) - const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath) - const libraryItemPayload = { - path: podcastPath, - relPath: podcastFilename, - folderId: folder.id, - libraryId: folder.libraryId, - ino: libraryItemFolderStats.ino, - mtimeMs: libraryItemFolderStats.mtimeMs || 0, - ctimeMs: libraryItemFolderStats.ctimeMs || 0, - birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, - media: { - metadata: newPodcastMetadata, - autoDownloadEpisodes + const podcastPayload = { + autoDownloadEpisodes, + metadata: { + title: feed.metadata.title, + author: feed.metadata.author, + description: feed.metadata.description, + releaseDate: '', + genres: [...feed.metadata.categories], + feedUrl: feed.metadata.feedUrl, + imageUrl: feed.metadata.image, + itunesPageUrl: '', + itunesId: '', + itunesArtistId: '', + language: '', + numEpisodes: feed.numEpisodes + } } + const podcast = await Database.podcastModel.createFromRequest(podcastPayload, transaction) + + newLibraryItem = await Database.libraryItemModel.create( + { + ino: libraryItemFolderStats.ino, + path: podcastPath, + relPath: podcastFilename, + 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, + libraryFolderId: folder.id + }, + { transaction } + ) + + await transaction.commit() + } 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() - libraryItem.setData('podcast', libraryItemPayload) + newLibraryItem.media = await newLibraryItem.getMediaExpanded() // Download and save cover image - if (newPodcastMetadata.imageUrl) { - // TODO: Scan cover image to library files + if (typeof feed.metadata.image === 'string' && feed.metadata.image.startsWith('http')) { // Podcast cover will always go into library item folder - const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, newPodcastMetadata.imageUrl, true) - if (coverResponse) { - if (coverResponse.error) { - Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Download cover error from "${newPodcastMetadata.imageUrl}": ${coverResponse.error}`) - } else if (coverResponse.cover) { - libraryItem.media.coverPath = coverResponse.cover + const coverResponse = await CoverManager.downloadCoverFromUrlNew(feed.metadata.image, newLibraryItem.id, newLibraryItem.path, true) + if (coverResponse.error) { + Logger.error(`[PodcastManager] Download cover error from "${feed.metadata.image}": ${coverResponse.error}`) + } else if (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', libraryItem.toJSONExpanded()) + SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded()) // Turn on podcast auto download cron if not already on - if (libraryItem.media.autoDownloadEpisodes) { - cronManager.checkUpdatePodcastCron(libraryItem) + if (newLibraryItem.media.autoDownloadEpisodes) { + cronManager.checkUpdatePodcastCron(newLibraryItem) } numPodcastsAdded++ } + const taskFinishedString = { text: `Added ${numPodcastsAdded} podcasts`, key: 'MessageTaskOpmlImportFinished', diff --git a/server/models/Podcast.js b/server/models/Podcast.js index fd471305..aa7afbac 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -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 * @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 * - * @param {Object} feedEpisode - object from rss feed + * @param {import('../utils/podcastUtils').RssPodcastEpisode} feedEpisode - object from rss feed * @returns {boolean} */ checkHasEpisodeByFeedEpisode(feedEpisode) { diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 4c9967f8..c1e66fdf 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -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 * @param {import('../Database').sequelize} sequelize diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index b1cdf43b..17d7484c 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -1,4 +1,3 @@ -const uuidv4 = require('uuid').v4 const fs = require('../libs/fsExtra') const Path = require('path') const Logger = require('../Logger') @@ -178,45 +177,6 @@ class LibraryItem { 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) { const json = this.toJSON() let hasUpdates = false diff --git a/server/objects/PodcastEpisodeDownload.js b/server/objects/PodcastEpisodeDownload.js index ecda4a47..ffdad9f0 100644 --- a/server/objects/PodcastEpisodeDownload.js +++ b/server/objects/PodcastEpisodeDownload.js @@ -6,8 +6,9 @@ const globals = require('../utils/globals') class PodcastEpisodeDownload { constructor() { this.id = null - /** @type {import('../objects/entities/PodcastEpisode')} */ - this.podcastEpisode = null + /** @type {import('../utils/podcastUtils').RssPodcastEpisode} */ + this.rssPodcastEpisode = null + this.url = null /** @type {import('../models/LibraryItem')} */ this.libraryItem = null @@ -17,7 +18,7 @@ class PodcastEpisodeDownload { this.isFinished = false this.failed = false - this.appendEpisodeId = false + this.appendRandomId = false this.startedAt = null this.createdAt = null @@ -27,22 +28,22 @@ class PodcastEpisodeDownload { toJSONForClient() { return { id: this.id, - episodeDisplayTitle: this.podcastEpisode?.title ?? null, + episodeDisplayTitle: this.rssPodcastEpisode?.title ?? null, url: this.url, libraryItemId: this.libraryItemId, libraryId: this.libraryId || null, isFinished: this.isFinished, failed: this.failed, - appendEpisodeId: this.appendEpisodeId, + appendRandomId: this.appendRandomId, startedAt: this.startedAt, createdAt: this.createdAt, finishedAt: this.finishedAt, 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', - publishedAt: this.podcastEpisode?.publishedAt ?? null + season: this.rssPodcastEpisode?.season ?? null, + episode: this.rssPodcastEpisode?.episode ?? null, + episodeType: this.rssPodcastEpisode?.episodeType ?? 'full', + publishedAt: this.rssPodcastEpisode?.publishedAt ?? null } } @@ -56,7 +57,7 @@ class PodcastEpisodeDownload { return 'mp3' } get enclosureType() { - const enclosureType = this.podcastEpisode?.enclosure?.type + const enclosureType = this.rssPodcastEpisode.enclosure.type return typeof enclosureType === 'string' ? enclosureType : null } /** @@ -69,10 +70,12 @@ class PodcastEpisodeDownload { if (this.enclosureType && !this.enclosureType.includes('mpeg')) return false return this.fileExtension === 'mp3' } - + get episodeTitle() { + return this.rssPodcastEpisode.title + } get targetFilename() { - const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : '' - const filename = `${this.podcastEpisode.title}${appendage}.${this.fileExtension}` + const appendage = this.appendRandomId ? ` (${uuidv4()})` : '' + const filename = `${this.rssPodcastEpisode.title}${appendage}.${this.fileExtension}` return sanitizeFilename(filename) } get targetPath() { @@ -84,19 +87,23 @@ class PodcastEpisodeDownload { get libraryItemId() { 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 {*} isAutoDownload * @param {*} libraryId */ - setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) { + setData(rssPodcastEpisode, libraryItem, isAutoDownload, libraryId) { this.id = uuidv4() - this.podcastEpisode = podcastEpisode + this.rssPodcastEpisode = rssPodcastEpisode - const url = podcastEpisode.enclosure.url + const url = rssPodcastEpisode.enclosure.url if (decodeURIComponent(url) !== url) { // Already encoded this.url = url diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index e759a0eb..6a3f4cf6 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -1,4 +1,3 @@ -const uuidv4 = require('uuid').v4 const { areEquivalent, copyValue } = require('../../utils/index') const AudioFile = require('../files/AudioFile') const AudioTrack = require('../files/AudioTrack') @@ -127,27 +126,6 @@ class PodcastEpisode { get enclosureUrl() { 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) { let hasUpdates = false diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 8d6b541d..5f43ebc8 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -132,18 +132,6 @@ class Podcast { get numTracks() { 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) { var json = this.toJSON() @@ -178,34 +166,10 @@ class Podcast { 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) { 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) { if (!episodeId) return null diff --git a/server/objects/metadata/PodcastMetadata.js b/server/objects/metadata/PodcastMetadata.js index 8300e93a..0df40df0 100644 --- a/server/objects/metadata/PodcastMetadata.js +++ b/server/objects/metadata/PodcastMetadata.js @@ -91,24 +91,6 @@ class PodcastMetadata { 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) { const json = this.toJSON() let hasUpdates = false diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index 06e20f1d..f86df9eb 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -125,7 +125,7 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { /** @type {import('../models/Podcast')} */ const podcast = podcastEpisodeDownload.libraryItem.media - const podcastEpisode = podcastEpisodeDownload.podcastEpisode + const podcastEpisode = podcastEpisodeDownload.rssPodcastEpisode const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0) const taggings = { @@ -144,7 +144,7 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { 'series-part': podcastEpisode.episode, title: podcastEpisode.title, 'title-sort': podcastEpisode.title, - year: podcastEpisode.pubYear, + year: podcastEpisodeDownload.pubYear, date: podcastEpisode.pubDate, releasedate: podcastEpisode.pubDate, 'itunes-id': podcast.itunesId, diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 26bd1733..d28c3b9d 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -4,6 +4,49 @@ const Logger = require('../Logger') const { xmlToJSON, levenshteinDistance } = require('./index') 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) { if (!json[key]?.length) return null return json[key][0] @@ -223,7 +266,7 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal * * @param {string} feedUrl * @param {boolean} [excludeEpisodeMetadata=false] - * @returns {Promise} + * @returns {Promise} */ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`)