mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-08-31 23:57:18 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8823c8b1c | ||
|
|
4a398f6113 | ||
|
|
69d1744496 | ||
|
|
0357dc90d4 | ||
|
|
6cd874dffc |
@@ -170,6 +170,12 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
libraryItemUpdated(libraryItem) {
|
||||||
|
const episode = libraryItem.media.episodes.find((e) => e.id === this.selectedEpisodeId)
|
||||||
|
if (episode) {
|
||||||
|
this.episodeItem = episode
|
||||||
|
}
|
||||||
|
},
|
||||||
hotkey(action) {
|
hotkey(action) {
|
||||||
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
|
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
|
||||||
this.goNextEpisode()
|
this.goNextEpisode()
|
||||||
@@ -178,9 +184,15 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
registerListeners() {
|
registerListeners() {
|
||||||
|
if (this.libraryItem) {
|
||||||
|
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||||
|
}
|
||||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||||
},
|
},
|
||||||
unregisterListeners() {
|
unregisterListeners() {
|
||||||
|
if (this.libraryItem) {
|
||||||
|
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||||
|
}
|
||||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -163,13 +163,10 @@ export default {
|
|||||||
|
|
||||||
this.isProcessing = false
|
this.isProcessing = false
|
||||||
if (updateResult) {
|
if (updateResult) {
|
||||||
if (updateResult) {
|
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
|
||||||
this.$toast.success(this.$strings.ToastItemUpdateSuccess)
|
return true
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -249,6 +249,9 @@ class LibraryItemController {
|
|||||||
|
|
||||||
const hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
|
const hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
|
req.libraryItem.changed('updatedAt', true)
|
||||||
|
await req.libraryItem.save()
|
||||||
|
|
||||||
if (isPodcastAutoDownloadUpdated) {
|
if (isPodcastAutoDownloadUpdated) {
|
||||||
this.cronManager.checkUpdatePodcastCron(req.libraryItem)
|
this.cronManager.checkUpdatePodcastCron(req.libraryItem)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,13 +13,16 @@ 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
|
||||||
*
|
*
|
||||||
* @typedef {Request & RequestUserObject} RequestWithUser
|
* @typedef {Request & RequestUserObject} RequestWithUser
|
||||||
|
*
|
||||||
|
* @typedef RequestEntityObject
|
||||||
|
* @property {import('../models/LibraryItem')} libraryItem
|
||||||
|
*
|
||||||
|
* @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem
|
||||||
*/
|
*/
|
||||||
|
|
||||||
class PodcastController {
|
class PodcastController {
|
||||||
@@ -37,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) {
|
||||||
@@ -78,48 +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
|
||||||
path: podcastPath,
|
const transaction = await Database.sequelize.transaction()
|
||||||
relPath,
|
try {
|
||||||
folderId: payload.folderId,
|
const podcast = await Database.podcastModel.createFromRequest(payload.media, transaction)
|
||||||
libraryId: payload.libraryId,
|
|
||||||
ino: libraryItemFolderStats.ino,
|
newLibraryItem = await Database.libraryItemModel.create(
|
||||||
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
{
|
||||||
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
ino: libraryItemFolderStats.ino,
|
||||||
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
path: podcastPath,
|
||||||
media: payload.media
|
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()
|
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) {
|
const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
|
||||||
libraryItem.media.coverPath = 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())
|
||||||
|
|
||||||
if (payload.episodesToDownload?.length) {
|
|
||||||
Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`)
|
|
||||||
this.podcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Turn on podcast auto download cron if not already on
|
// Turn on podcast auto download cron if not already on
|
||||||
if (libraryItem.media.autoDownloadEpisodes) {
|
if (newLibraryItem.media.autoDownloadEpisodes) {
|
||||||
this.cronManager.checkUpdatePodcastCron(libraryItem)
|
this.cronManager.checkUpdatePodcastCron(newLibraryItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +259,7 @@ class PodcastController {
|
|||||||
*
|
*
|
||||||
* @this import('../routers/ApiRouter')
|
* @this import('../routers/ApiRouter')
|
||||||
*
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithLibraryItem} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async checkNewEpisodes(req, res) {
|
async checkNewEpisodes(req, res) {
|
||||||
@@ -222,15 +268,14 @@ class PodcastController {
|
|||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
var libraryItem = req.libraryItem
|
if (!req.libraryItem.media.feedURL) {
|
||||||
if (!libraryItem.media.metadata.feedUrl) {
|
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${req.libraryItem.id}`)
|
||||||
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`)
|
return res.status(400).send('Podcast has no rss feed url')
|
||||||
return res.status(500).send('Podcast has no rss feed url')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3
|
const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3
|
||||||
|
|
||||||
var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload)
|
const newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(req.libraryItem, maxEpisodesToDownload)
|
||||||
res.json({
|
res.json({
|
||||||
episodes: newEpisodes || []
|
episodes: newEpisodes || []
|
||||||
})
|
})
|
||||||
@@ -258,23 +303,28 @@ class PodcastController {
|
|||||||
*
|
*
|
||||||
* @this {import('../routers/ApiRouter')}
|
* @this {import('../routers/ApiRouter')}
|
||||||
*
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithLibraryItem} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
getEpisodeDownloads(req, res) {
|
getEpisodeDownloads(req, res) {
|
||||||
var libraryItem = req.libraryItem
|
const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id)
|
||||||
|
|
||||||
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
|
|
||||||
res.json({
|
res.json({
|
||||||
downloads: downloadsInQueue.map((d) => d.toJSONForClient())
|
downloads: downloadsInQueue.map((d) => d.toJSONForClient())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: /api/podcasts/:id/search-episode
|
||||||
|
* Search for an episode in a podcast
|
||||||
|
*
|
||||||
|
* @param {RequestWithLibraryItem} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
async findEpisode(req, res) {
|
async findEpisode(req, res) {
|
||||||
const rssFeedUrl = req.libraryItem.media.metadata.feedUrl
|
const rssFeedUrl = req.libraryItem.media.feedURL
|
||||||
if (!rssFeedUrl) {
|
if (!rssFeedUrl) {
|
||||||
Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`)
|
Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`)
|
||||||
return res.status(500).send('Podcast does not have an RSS feed URL')
|
return res.status(400).send('Podcast does not have an RSS feed URL')
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchTitle = req.query.title
|
const searchTitle = req.query.title
|
||||||
@@ -292,7 +342,7 @@ class PodcastController {
|
|||||||
*
|
*
|
||||||
* @this {import('../routers/ApiRouter')}
|
* @this {import('../routers/ApiRouter')}
|
||||||
*
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithLibraryItem} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async downloadEpisodes(req, res) {
|
async downloadEpisodes(req, res) {
|
||||||
@@ -300,13 +350,13 @@ class PodcastController {
|
|||||||
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`)
|
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`)
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
const libraryItem = req.libraryItem
|
|
||||||
const episodes = req.body
|
const episodes = req.body
|
||||||
if (!episodes?.length) {
|
if (!Array.isArray(episodes) || !episodes.length) {
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes)
|
this.podcastManager.downloadPodcastEpisodes(req.libraryItem, episodes)
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,7 +365,7 @@ class PodcastController {
|
|||||||
*
|
*
|
||||||
* @this {import('../routers/ApiRouter')}
|
* @this {import('../routers/ApiRouter')}
|
||||||
*
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithLibraryItem} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async quickMatchEpisodes(req, res) {
|
async quickMatchEpisodes(req, res) {
|
||||||
@@ -325,10 +375,11 @@ class PodcastController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const overrideDetails = req.query.override === '1'
|
const overrideDetails = req.query.override === '1'
|
||||||
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails })
|
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem)
|
||||||
|
const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(oldLibraryItem, { overrideDetails })
|
||||||
if (episodesUpdated) {
|
if (episodesUpdated) {
|
||||||
await Database.updateLibraryItem(req.libraryItem)
|
await Database.updateLibraryItem(oldLibraryItem)
|
||||||
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded())
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -339,58 +390,76 @@ class PodcastController {
|
|||||||
/**
|
/**
|
||||||
* PATCH: /api/podcasts/:id/episode/:episodeId
|
* PATCH: /api/podcasts/:id/episode/:episodeId
|
||||||
*
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithLibraryItem} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async updateEpisode(req, res) {
|
async updateEpisode(req, res) {
|
||||||
const libraryItem = req.libraryItem
|
/** @type {import('../models/PodcastEpisode')} */
|
||||||
|
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === req.params.episodeId)
|
||||||
var episodeId = req.params.episodeId
|
if (!episode) {
|
||||||
if (!libraryItem.media.checkHasEpisode(episodeId)) {
|
|
||||||
return res.status(404).send('Episode not found')
|
return res.status(404).send('Episode not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryItem.media.updateEpisode(episodeId, req.body)) {
|
const updatePayload = {}
|
||||||
await Database.updateLibraryItem(libraryItem)
|
const supportedStringKeys = ['title', 'subtitle', 'description', 'pubDate', 'episode', 'season', 'episodeType']
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
for (const key in req.body) {
|
||||||
|
if (supportedStringKeys.includes(key) && typeof req.body[key] === 'string') {
|
||||||
|
updatePayload[key] = req.body[key]
|
||||||
|
} else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) {
|
||||||
|
updatePayload[key] = req.body[key]
|
||||||
|
} else if (key === 'publishedAt' && typeof req.body[key] === 'number') {
|
||||||
|
updatePayload[key] = req.body[key]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(libraryItem.toJSONExpanded())
|
if (Object.keys(updatePayload).length) {
|
||||||
|
episode.set(updatePayload)
|
||||||
|
if (episode.changed()) {
|
||||||
|
Logger.info(`[PodcastController] Updated episode "${episode.title}" keys`, episode.changed())
|
||||||
|
await episode.save()
|
||||||
|
|
||||||
|
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||||
|
} else {
|
||||||
|
Logger.info(`[PodcastController] No changes to episode "${episode.title}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(req.libraryItem.toOldJSONExpanded())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET: /api/podcasts/:id/episode/:episodeId
|
* GET: /api/podcasts/:id/episode/:episodeId
|
||||||
*
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithLibraryItem} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async getEpisode(req, res) {
|
async getEpisode(req, res) {
|
||||||
const episodeId = req.params.episodeId
|
const episodeId = req.params.episodeId
|
||||||
const libraryItem = req.libraryItem
|
|
||||||
|
|
||||||
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
|
/** @type {import('../models/PodcastEpisode')} */
|
||||||
|
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(episode)
|
res.json(episode.toOldJSON(req.libraryItem.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE: /api/podcasts/:id/episode/:episodeId
|
* DELETE: /api/podcasts/:id/episode/:episodeId
|
||||||
*
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithLibraryItem} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async removeEpisode(req, res) {
|
async removeEpisode(req, res) {
|
||||||
const episodeId = req.params.episodeId
|
const episodeId = req.params.episodeId
|
||||||
const libraryItem = req.libraryItem
|
|
||||||
const hardDelete = req.query.hard === '1'
|
const hardDelete = req.query.hard === '1'
|
||||||
|
|
||||||
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
|
/** @type {import('../models/PodcastEpisode')} */
|
||||||
|
const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId)
|
||||||
if (!episode) {
|
if (!episode) {
|
||||||
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`)
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,36 +476,8 @@ class PodcastController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove episode from Podcast and library file
|
// Remove episode from playlists
|
||||||
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
|
await Database.playlistModel.removeMediaItemsFromPlaylists([episodeId])
|
||||||
if (episodeRemoved?.audioFile) {
|
|
||||||
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update/remove playlists that had this podcast episode
|
|
||||||
const playlistMediaItems = await Database.playlistMediaItemModel.findAll({
|
|
||||||
where: {
|
|
||||||
mediaItemId: episodeId
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
model: Database.playlistModel,
|
|
||||||
include: Database.playlistMediaItemModel
|
|
||||||
}
|
|
||||||
})
|
|
||||||
for (const pmi of playlistMediaItems) {
|
|
||||||
const numItems = pmi.playlist.playlistMediaItems.length - 1
|
|
||||||
|
|
||||||
if (!numItems) {
|
|
||||||
Logger.info(`[PodcastController] Playlist "${pmi.playlist.name}" has no more items - removing it`)
|
|
||||||
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
|
|
||||||
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded)
|
|
||||||
await pmi.playlist.destroy()
|
|
||||||
} else {
|
|
||||||
await pmi.destroy()
|
|
||||||
const jsonExpanded = await pmi.playlist.getOldJsonExpanded()
|
|
||||||
SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_updated', jsonExpanded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove media progress for this episode
|
// Remove media progress for this episode
|
||||||
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
|
const mediaProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||||
@@ -448,9 +489,16 @@ class PodcastController {
|
|||||||
Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`)
|
Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await Database.updateLibraryItem(libraryItem)
|
// Remove episode
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
await episode.destroy()
|
||||||
res.json(libraryItem.toJSON())
|
|
||||||
|
// Remove library file
|
||||||
|
req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((file) => file.ino !== episode.audioFile.ino)
|
||||||
|
req.libraryItem.changed('libraryFiles', true)
|
||||||
|
await req.libraryItem.save()
|
||||||
|
|
||||||
|
SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded())
|
||||||
|
res.json(req.libraryItem.toOldJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -460,15 +508,15 @@ class PodcastController {
|
|||||||
* @param {NextFunction} next
|
* @param {NextFunction} next
|
||||||
*/
|
*/
|
||||||
async middleware(req, res, next) {
|
async middleware(req, res, next) {
|
||||||
const item = await Database.libraryItemModel.getOldById(req.params.id)
|
const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
|
||||||
if (!item?.media) return res.sendStatus(404)
|
if (!libraryItem?.media) return res.sendStatus(404)
|
||||||
|
|
||||||
if (!item.isPodcast) {
|
if (!libraryItem.isPodcast) {
|
||||||
return res.sendStatus(500)
|
return res.sendStatus(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check user can access this library item
|
// Check user can access this library item
|
||||||
if (!req.user.checkCanAccessLibraryItem(item)) {
|
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +528,7 @@ class PodcastController {
|
|||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.libraryItem = item
|
req.libraryItem = libraryItem
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ class CronManager {
|
|||||||
// Get podcast library items to check
|
// Get podcast library items to check
|
||||||
const libraryItems = []
|
const libraryItems = []
|
||||||
for (const libraryItemId of libraryItemIds) {
|
for (const libraryItemId of libraryItemIds) {
|
||||||
const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId)
|
const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId)
|
||||||
if (!libraryItem) {
|
if (!libraryItem) {
|
||||||
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
|
Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`)
|
||||||
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItemId) // Filter it out
|
podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItemId) // Filter it out
|
||||||
@@ -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 || '',
|
||||||
|
|||||||
+238
-130
@@ -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() {
|
||||||
@@ -52,15 +51,16 @@ class PodcastManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
|
* @param {import('../utils/podcastUtils').RssPodcastEpisode[]} episodesToDownload
|
||||||
|
* @param {boolean} isAutoDownload - If this download was triggered by auto download
|
||||||
|
*/
|
||||||
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
|
async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) {
|
||||||
let index = Math.max(...libraryItem.media.episodes.filter((ep) => ep.index == null || isNaN(ep.index)).map((ep) => Number(ep.index))) + 1
|
|
||||||
for (const ep of episodesToDownload) {
|
for (const ep of episodesToDownload) {
|
||||||
const newPe = new PodcastEpisode()
|
|
||||||
newPe.setData(ep, index++)
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,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
|
||||||
@@ -140,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()
|
||||||
}
|
}
|
||||||
@@ -166,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) {
|
||||||
@@ -217,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
|
||||||
if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) {
|
*/
|
||||||
smallestPublishedAt = ep.publishedAt
|
async getRemoveOldestEpisode(libraryItem, episodeIdJustDownloaded) {
|
||||||
oldestEpisode = ep
|
let smallestPublishedAt = 0
|
||||||
}
|
/** @type {import('../models/PodcastEpisode')} */
|
||||||
})
|
let oldestEpisode = null
|
||||||
// TODO: Should we check for open playback sessions for this episode?
|
|
||||||
// TODO: remove all user progress for this episode
|
/** @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) {
|
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)
|
||||||
@@ -263,18 +285,23 @@ class PodcastManager {
|
|||||||
return newAudioFile
|
return newAudioFile
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns false if auto download episodes was disabled (disabled if reaches max failed checks)
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
|
* @returns {Promise<boolean>} - Returns false if auto download episodes was disabled (disabled if reaches max failed checks)
|
||||||
|
*/
|
||||||
async runEpisodeCheck(libraryItem) {
|
async runEpisodeCheck(libraryItem) {
|
||||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
|
||||||
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
|
const latestEpisodePublishedAt = libraryItem.media.getLatestEpisodePublishedAt()
|
||||||
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
|
|
||||||
|
Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" | Last check: ${new Date(lastEpisodeCheck)} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
|
||||||
|
|
||||||
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
|
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
|
||||||
// lastEpisodeCheckDate will be the current time when adding a new podcast
|
// lastEpisodeCheckDate will be the current time when adding a new podcast
|
||||||
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
|
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
|
||||||
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
|
Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
|
||||||
|
|
||||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload)
|
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) {
|
||||||
@@ -283,37 +310,48 @@ class PodcastManager {
|
|||||||
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
|
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
|
||||||
this.failedCheckMap[libraryItem.id]++
|
this.failedCheckMap[libraryItem.id]++
|
||||||
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
|
if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) {
|
||||||
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`)
|
Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`)
|
||||||
libraryItem.media.autoDownloadEpisodes = false
|
libraryItem.media.autoDownloadEpisodes = false
|
||||||
delete this.failedCheckMap[libraryItem.id]
|
delete this.failedCheckMap[libraryItem.id]
|
||||||
} else {
|
} else {
|
||||||
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
|
Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}"`)
|
||||||
}
|
}
|
||||||
} else if (newEpisodes.length) {
|
} else if (newEpisodes.length) {
|
||||||
delete this.failedCheckMap[libraryItem.id]
|
delete this.failedCheckMap[libraryItem.id]
|
||||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`)
|
||||||
this.downloadPodcastEpisodes(libraryItem, newEpisodes, true)
|
this.downloadPodcastEpisodes(libraryItem, newEpisodes, true)
|
||||||
} else {
|
} else {
|
||||||
delete this.failedCheckMap[libraryItem.id]
|
delete this.failedCheckMap[libraryItem.id]
|
||||||
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
|
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.title}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryItem.media.lastEpisodeCheck = Date.now()
|
libraryItem.media.lastEpisodeCheck = new Date()
|
||||||
libraryItem.updatedAt = Date.now()
|
await libraryItem.media.save()
|
||||||
await Database.updateLibraryItem(libraryItem)
|
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
libraryItem.changed('updatedAt', true)
|
||||||
|
await libraryItem.save()
|
||||||
|
|
||||||
|
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||||
|
|
||||||
return libraryItem.media.autoDownloadEpisodes
|
return libraryItem.media.autoDownloadEpisodes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../models/LibraryItem')} podcastLibraryItem
|
||||||
|
* @param {number} dateToCheckForEpisodesAfter - Unix timestamp
|
||||||
|
* @param {number} maxNewEpisodes
|
||||||
|
* @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]|null>}
|
||||||
|
*/
|
||||||
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) {
|
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) {
|
||||||
if (!podcastLibraryItem.media.metadata.feedUrl) {
|
if (!podcastLibraryItem.media.feedURL) {
|
||||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
|
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`)
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
const feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
|
const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL)
|
||||||
if (!feed?.episodes) {
|
if (!feed?.episodes) {
|
||||||
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed)
|
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed)
|
||||||
return false
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter new and not already has
|
// Filter new and not already has
|
||||||
@@ -326,23 +364,34 @@ class PodcastManager {
|
|||||||
return newEpisodes
|
return newEpisodes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
|
* @param {*} maxEpisodesToDownload
|
||||||
|
* @returns {Promise<import('../utils/podcastUtils').RssPodcastEpisode[]>}
|
||||||
|
*/
|
||||||
async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) {
|
async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) {
|
||||||
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0
|
||||||
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem.media.lastEpisodeCheck : 'Never'
|
||||||
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck, maxEpisodesToDownload)
|
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
||||||
if (newEpisodes.length) {
|
|
||||||
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
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)
|
this.downloadPodcastEpisodes(libraryItem, newEpisodes, false)
|
||||||
} else {
|
} else {
|
||||||
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.metadata.title}"`)
|
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.title}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryItem.media.lastEpisodeCheck = Date.now()
|
libraryItem.media.lastEpisodeCheck = new Date()
|
||||||
libraryItem.updatedAt = Date.now()
|
await libraryItem.media.save()
|
||||||
await Database.updateLibraryItem(libraryItem)
|
|
||||||
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
|
|
||||||
|
|
||||||
return newEpisodes
|
libraryItem.changed('updatedAt', true)
|
||||||
|
await libraryItem.save()
|
||||||
|
|
||||||
|
SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded())
|
||||||
|
|
||||||
|
return newEpisodes || []
|
||||||
}
|
}
|
||||||
|
|
||||||
async findEpisode(rssFeedUrl, searchTitle) {
|
async findEpisode(rssFeedUrl, searchTitle) {
|
||||||
@@ -518,64 +567,123 @@ class PodcastManager {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPodcastMetadata = {
|
let newLibraryItem = null
|
||||||
title: feed.metadata.title,
|
const transaction = await Database.sequelize.transaction()
|
||||||
author: feed.metadata.author,
|
try {
|
||||||
description: feed.metadata.description,
|
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
||||||
releaseDate: '',
|
|
||||||
genres: [...feed.metadata.categories],
|
|
||||||
feedUrl: feed.metadata.feedUrl,
|
|
||||||
imageUrl: feed.metadata.image,
|
|
||||||
itunesPageUrl: '',
|
|
||||||
itunesId: '',
|
|
||||||
itunesArtistId: '',
|
|
||||||
language: '',
|
|
||||||
numEpisodes: feed.numEpisodes
|
|
||||||
}
|
|
||||||
|
|
||||||
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
const podcastPayload = {
|
||||||
const libraryItemPayload = {
|
autoDownloadEpisodes,
|
||||||
path: podcastPath,
|
metadata: {
|
||||||
relPath: podcastFilename,
|
title: feed.metadata.title,
|
||||||
folderId: folder.id,
|
author: feed.metadata.author,
|
||||||
libraryId: folder.libraryId,
|
description: feed.metadata.description,
|
||||||
ino: libraryItemFolderStats.ino,
|
releaseDate: '',
|
||||||
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
genres: [...feed.metadata.categories],
|
||||||
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
feedUrl: feed.metadata.feedUrl,
|
||||||
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
imageUrl: feed.metadata.image,
|
||||||
media: {
|
itunesPageUrl: '',
|
||||||
metadata: newPodcastMetadata,
|
itunesId: '',
|
||||||
autoDownloadEpisodes
|
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()
|
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] Download cover error from "${feed.metadata.image}": ${coverResponse.error}`)
|
||||||
Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Download cover error from "${newPodcastMetadata.imageUrl}": ${coverResponse.error}`)
|
} else if (coverResponse.cover) {
|
||||||
} else if (coverResponse.cover) {
|
const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover)
|
||||||
libraryItem.media.coverPath = 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
|
||||||
@@ -259,6 +298,10 @@ class Podcast extends Model {
|
|||||||
this.autoDownloadSchedule = payload.autoDownloadSchedule
|
this.autoDownloadSchedule = payload.autoDownloadSchedule
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
|
if (typeof payload.lastEpisodeCheck === 'number' && payload.lastEpisodeCheck !== this.lastEpisodeCheck?.valueOf()) {
|
||||||
|
this.lastEpisodeCheck = payload.lastEpisodeCheck
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
|
||||||
const numberKeys = ['maxEpisodesToKeep', 'maxNewEpisodesToDownload']
|
const numberKeys = ['maxEpisodesToKeep', 'maxNewEpisodesToDownload']
|
||||||
numberKeys.forEach((key) => {
|
numberKeys.forEach((key) => {
|
||||||
@@ -348,6 +391,31 @@ class Podcast extends Model {
|
|||||||
return episode.duration
|
return episode.duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns {number} - Unix timestamp
|
||||||
|
*/
|
||||||
|
getLatestEpisodePublishedAt() {
|
||||||
|
return this.podcastEpisodes.reduce((latest, episode) => {
|
||||||
|
if (episode.publishedAt?.valueOf() > latest) {
|
||||||
|
return episode.publishedAt.valueOf()
|
||||||
|
}
|
||||||
|
return latest
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for checking if an rss feed episode is already in the podcast
|
||||||
|
*
|
||||||
|
* @param {import('../utils/podcastUtils').RssPodcastEpisode} feedEpisode - object from rss feed
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
checkHasEpisodeByFeedEpisode(feedEpisode) {
|
||||||
|
const guid = feedEpisode.guid
|
||||||
|
const url = feedEpisode.enclosure.url
|
||||||
|
return this.podcastEpisodes.some((ep) => ep.checkMatchesGuidOrEnclosureUrl(guid, url))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Old model kept metadata in a separate object
|
* Old model kept metadata in a separate object
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -143,6 +177,23 @@ class PodcastEpisode extends Model {
|
|||||||
return this.audioFile?.duration || 0
|
return this.audioFile?.duration || 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used for matching the episode with an episode in the RSS feed
|
||||||
|
*
|
||||||
|
* @param {string} guid
|
||||||
|
* @param {string} enclosureURL
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
checkMatchesGuidOrEnclosureUrl(guid, enclosureURL) {
|
||||||
|
if (this.extraData?.guid && this.extraData.guid === guid) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (this.enclosureURL && this.enclosureURL === enclosureURL) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used in client players
|
* Used in client players
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,11 @@ const globals = require('../utils/globals')
|
|||||||
class PodcastEpisodeDownload {
|
class PodcastEpisodeDownload {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.id = null
|
this.id = null
|
||||||
this.podcastEpisode = null
|
/** @type {import('../utils/podcastUtils').RssPodcastEpisode} */
|
||||||
|
this.rssPodcastEpisode = null
|
||||||
|
|
||||||
this.url = null
|
this.url = null
|
||||||
|
/** @type {import('../models/LibraryItem')} */
|
||||||
this.libraryItem = null
|
this.libraryItem = null
|
||||||
this.libraryId = null
|
this.libraryId = null
|
||||||
|
|
||||||
@@ -15,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
|
||||||
@@ -25,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.libraryItem?.id || null,
|
libraryItemId: this.libraryItemId,
|
||||||
libraryId: this.libraryId || null,
|
libraryId: this.libraryId || null,
|
||||||
isFinished: this.isFinished,
|
isFinished: this.isFinished,
|
||||||
failed: this.failed,
|
failed: this.failed,
|
||||||
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.metadata.title ?? null,
|
podcastTitle: this.libraryItem?.media.title ?? null,
|
||||||
podcastExplicit: !!this.libraryItem?.media.metadata.explicit,
|
podcastExplicit: !!this.libraryItem?.media.explicit,
|
||||||
season: this.podcastEpisode?.season ?? null,
|
season: this.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,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
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -67,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() {
|
||||||
@@ -80,14 +85,25 @@ class PodcastEpisodeDownload {
|
|||||||
return this.targetFilename
|
return this.targetFilename
|
||||||
}
|
}
|
||||||
get libraryItemId() {
|
get libraryItemId() {
|
||||||
return this.libraryItem ? this.libraryItem.id : null
|
return this.libraryItem?.id || null
|
||||||
|
}
|
||||||
|
get pubYear() {
|
||||||
|
if (!this.rssPodcastEpisode.publishedAt) return null
|
||||||
|
return new Date(this.rssPodcastEpisode.publishedAt).getFullYear()
|
||||||
}
|
}
|
||||||
|
|
||||||
setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) {
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode - from rss feed
|
||||||
|
* @param {import('../models/LibraryItem')} libraryItem
|
||||||
|
* @param {*} isAutoDownload
|
||||||
|
* @param {*} 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
|
||||||
@@ -167,10 +145,5 @@ class PodcastEpisode {
|
|||||||
}
|
}
|
||||||
return hasUpdates
|
return hasUpdates
|
||||||
}
|
}
|
||||||
|
|
||||||
checkEqualsEnclosureUrl(url) {
|
|
||||||
if (!this.enclosure?.url) return false
|
|
||||||
return this.enclosure.url == url
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
module.exports = PodcastEpisode
|
module.exports = PodcastEpisode
|
||||||
|
|||||||
@@ -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,38 +166,9 @@ 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)
|
||||||
}
|
}
|
||||||
checkHasEpisodeByFeedEpisode(feedEpisode) {
|
|
||||||
const guid = feedEpisode.guid
|
|
||||||
const url = feedEpisode.enclosure.url
|
|
||||||
return this.episodes.some((ep) => (ep.guid && ep.guid === guid) || ep.checkEqualsEnclosureUrl(url))
|
|
||||||
}
|
|
||||||
|
|
||||||
addPodcastEpisode(podcastEpisode) {
|
|
||||||
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
|
||||||
|
|||||||
@@ -97,6 +97,11 @@ async function resizeImage(filePath, outputPath, width, height) {
|
|||||||
}
|
}
|
||||||
module.exports.resizeImage = resizeImage
|
module.exports.resizeImage = resizeImage
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
||||||
return new Promise(async (resolve) => {
|
return new Promise(async (resolve) => {
|
||||||
const response = await axios({
|
const response = await axios({
|
||||||
@@ -118,32 +123,33 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
|||||||
ffmpeg.addOption('-loglevel debug') // Debug logs printed on error
|
ffmpeg.addOption('-loglevel debug') // Debug logs printed on error
|
||||||
ffmpeg.outputOptions('-c:a', 'copy', '-map', '0:a', '-metadata', 'podcast=1')
|
ffmpeg.outputOptions('-c:a', 'copy', '-map', '0:a', '-metadata', 'podcast=1')
|
||||||
|
|
||||||
const podcastMetadata = podcastEpisodeDownload.libraryItem.media.metadata
|
/** @type {import('../models/Podcast')} */
|
||||||
const podcastEpisode = podcastEpisodeDownload.podcastEpisode
|
const podcast = podcastEpisodeDownload.libraryItem.media
|
||||||
|
const podcastEpisode = podcastEpisodeDownload.rssPodcastEpisode
|
||||||
const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0)
|
const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0)
|
||||||
|
|
||||||
const taggings = {
|
const taggings = {
|
||||||
album: podcastMetadata.title,
|
album: podcast.title,
|
||||||
'album-sort': podcastMetadata.title,
|
'album-sort': podcast.title,
|
||||||
artist: podcastMetadata.author,
|
artist: podcast.author,
|
||||||
'artist-sort': podcastMetadata.author,
|
'artist-sort': podcast.author,
|
||||||
comment: podcastEpisode.description,
|
comment: podcastEpisode.description,
|
||||||
subtitle: podcastEpisode.subtitle,
|
subtitle: podcastEpisode.subtitle,
|
||||||
disc: podcastEpisode.season,
|
disc: podcastEpisode.season,
|
||||||
genre: podcastMetadata.genres.length ? podcastMetadata.genres.join(';') : null,
|
genre: podcast.genres.length ? podcast.genres.join(';') : null,
|
||||||
language: podcastMetadata.language,
|
language: podcast.language,
|
||||||
MVNM: podcastMetadata.title,
|
MVNM: podcast.title,
|
||||||
MVIN: podcastEpisode.episode,
|
MVIN: podcastEpisode.episode,
|
||||||
track: podcastEpisode.episode,
|
track: podcastEpisode.episode,
|
||||||
'series-part': podcastEpisode.episode,
|
'series-part': podcastEpisode.episode,
|
||||||
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': podcastMetadata.itunesId,
|
'itunes-id': podcast.itunesId,
|
||||||
'podcast-type': podcastMetadata.type,
|
'podcast-type': podcast.podcastType,
|
||||||
'episode-type': podcastMetadata.episodeType
|
'episode-type': podcastEpisode.episodeType
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tag in taggings) {
|
for (const tag in taggings) {
|
||||||
|
|||||||
@@ -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}"`)
|
||||||
|
|||||||
Reference in New Issue
Block a user