mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-02-10 00:18:06 +01:00
Update podcasts to new library item model
This commit is contained in:
parent
4a398f6113
commit
d8823c8b1c
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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 || '',
|
||||||
|
@ -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',
|
||||||
|
@ -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) {
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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}"`)
|
||||||
|
Loading…
Reference in New Issue
Block a user