mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-22 00:07:52 +01:00
Merge pull request #3795 from advplyr/migrate-podcasts-new-library-item-2
Update podcasts to new library item model
This commit is contained in:
commit
7fae25a726
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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 || '',
|
||||
|
@ -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<boolean>} - 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<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) {
|
||||
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<AudioFile|null>}
|
||||
*/
|
||||
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<import('../utils/podcastUtils').RssPodcastEpisode[]|null>}
|
||||
*/
|
||||
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<import('../utils/podcastUtils').RssPodcastEpisode[]>}
|
||||
*/
|
||||
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',
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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<RssPodcast|null>}
|
||||
*/
|
||||
module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => {
|
||||
Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`)
|
||||
|
Loading…
Reference in New Issue
Block a user