Merge branch 'refs/heads/master' into mf/rssInboundManager

# Conflicts:
#	client/components/widgets/PodcastDetailsEdit.vue
#	client/pages/config/rss-feeds.vue
#	client/strings/en-us.json
#	server/controllers/PodcastController.js
#	server/models/Podcast.js
#	server/routers/ApiRouter.js
#	server/utils/queries/libraryItemsPodcastFilters.js
This commit is contained in:
mfcar 2024-11-01 15:17:06 +00:00
commit 2d2c6242dd
No known key found for this signature in database
462 changed files with 33361 additions and 21220 deletions

View file

@ -1,3 +1,4 @@
const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
@ -13,8 +14,23 @@ const CoverManager = require('../managers/CoverManager')
const LibraryItem = require('../objects/LibraryItem')
class PodcastController {
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*/
class PodcastController {
/**
* POST /api/podcasts
* Create podcast
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async create(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to create podcast`)
@ -22,13 +38,13 @@ class PodcastController {
}
const payload = req.body
const library = await Database.libraryModel.getOldById(payload.libraryId)
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
if (!library) {
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
return res.status(404).send('Library not found')
}
const folder = library.folders.find(fold => fold.id === payload.folderId)
const folder = library.libraryFolders.find((fold) => fold.id === payload.folderId)
if (!folder) {
Logger.error(`[PodcastController] Create: Folder not found "${payload.folderId}"`)
return res.status(404).send('Folder not found')
@ -37,20 +53,24 @@ class PodcastController {
const podcastPath = filePathToPOSIX(payload.path)
// Check if a library item with this podcast folder exists already
const existingLibraryItem = (await Database.libraryItemModel.count({
where: {
path: podcastPath
}
})) > 0
const existingLibraryItem =
(await Database.libraryItemModel.count({
where: {
path: podcastPath
}
})) > 0
if (existingLibraryItem) {
Logger.error(`[PodcastController] Podcast already exists at path "${podcastPath}"`)
return res.status(400).send('Podcast already exists')
}
const success = await fs.ensureDir(podcastPath).then(() => true).catch((error) => {
Logger.error(`[PodcastController] Failed to ensure podcast dir "${podcastPath}"`, error)
return false
})
const success = await fs
.ensureDir(podcastPath)
.then(() => true)
.catch((error) => {
Logger.error(`[PodcastController] Failed to ensure podcast dir "${podcastPath}"`, error)
return false
})
if (!success) return res.status(400).send('Invalid podcast path')
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
@ -109,8 +129,8 @@ class PodcastController {
* @typedef getPodcastFeedReqBody
* @property {string} rssFeed
*
* @param {import('express').Request<{}, {}, getPodcastFeedReqBody, {}} req
* @param {import('express').Response} res
* @param {Request<{}, {}, getPodcastFeedReqBody, {}> & RequestUserObject} req
* @param {Response} res
*/
async getPodcastFeed(req, res) {
if (!req.user.isAdminOrUp) {
@ -168,6 +188,14 @@ class PodcastController {
}
}
/**
* POST: /api/podcasts/opml
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getFeedsFromOPMLText(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get feeds from opml`)
@ -178,13 +206,57 @@ class PodcastController {
return res.sendStatus(400)
}
const rssFeedsData = await this.podcastManager.getOPMLFeeds(req.body.opmlText)
res.json(rssFeedsData)
res.json({
feeds: this.podcastManager.getParsedOPMLFileFeeds(req.body.opmlText)
})
}
/**
* POST: /api/podcasts/opml/create
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async bulkCreatePodcastsFromOpmlFeedUrls(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to bulk create podcasts`)
return res.sendStatus(403)
}
const rssFeeds = req.body.feeds
if (!Array.isArray(rssFeeds) || !rssFeeds.length || rssFeeds.some((feed) => !validateUrl(feed))) {
return res.status(400).send('Invalid request body. "feeds" must be an array of RSS feed URLs')
}
const libraryId = req.body.libraryId
const folderId = req.body.folderId
if (!libraryId || !folderId) {
return res.status(400).send('Invalid request body. "libraryId" and "folderId" are required')
}
const folder = await Database.libraryFolderModel.findByPk(folderId)
if (!folder || folder.libraryId !== libraryId) {
return res.status(404).send('Folder not found')
}
const autoDownloadEpisodes = !!req.body.autoDownloadEpisodes
this.podcastManager.createPodcastsFromFeedUrls(rssFeeds, folder, autoDownloadEpisodes, this.cronManager)
res.sendStatus(200)
}
/**
* GET: /api/podcasts/:id/checknew
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async checkNewEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user)
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to check/download episodes`)
return res.sendStatus(403)
}
@ -202,21 +274,37 @@ class PodcastController {
})
}
/**
* GET: /api/podcasts/:id/clear-queue
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {Response} res
*/
clearEpisodeDownloadQueue(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempting to clear download queue "${req.user.username}"`)
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempting to clear download queue`)
return res.sendStatus(403)
}
this.podcastManager.clearDownloadQueue(req.params.id)
res.sendStatus(200)
}
/**
* GET: /api/podcasts/:id/downloads
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {Response} res
*/
getEpisodeDownloads(req, res) {
var libraryItem = req.libraryItem
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
res.json({
downloads: downloadsInQueue.map(d => d.toJSONForClient())
downloads: downloadsInQueue.map((d) => d.toJSONForClient())
})
}
@ -227,8 +315,8 @@ class PodcastController {
return res.status(500).send('Podcast does not have an RSS feed URL')
}
var searchTitle = req.query.title
if (!searchTitle) {
const searchTitle = req.query.title
if (!searchTitle || typeof searchTitle !== 'string') {
return res.sendStatus(500)
}
const episodes = await findMatchingEpisodes(rssFeedUrl, searchTitle)
@ -237,9 +325,17 @@ class PodcastController {
})
}
/**
* POST: /api/podcasts/:id/download-episodes
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async downloadEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`)
return res.sendStatus(403)
}
const libraryItem = req.libraryItem
@ -252,10 +348,17 @@ class PodcastController {
res.sendStatus(200)
}
// POST: api/podcasts/:id/match-episodes
/**
* POST: /api/podcasts/:id/match-episodes
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async quickMatchEpisodes(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`)
return res.sendStatus(403)
}
@ -271,6 +374,12 @@ class PodcastController {
})
}
/**
* PATCH: /api/podcasts/:id/episode/:episodeId
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async updateEpisode(req, res) {
const libraryItem = req.libraryItem
@ -287,12 +396,17 @@ class PodcastController {
res.json(libraryItem.toJSONExpanded())
}
// GET: api/podcasts/:id/episode/:episodeId
/**
* GET: /api/podcasts/:id/episode/:episodeId
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getEpisode(req, res) {
const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
const episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
return res.sendStatus(404)
@ -301,13 +415,18 @@ class PodcastController {
res.json(episode)
}
// DELETE: api/podcasts/:id/episode/:episodeId
/**
* DELETE: /api/podcasts/:id/episode/:episodeId
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async removeEpisode(req, res) {
const episodeId = req.params.episodeId
const libraryItem = req.libraryItem
const hardDelete = req.query.hard === '1'
const episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId)
if (!episode) {
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
return res.sendStatus(404)
@ -316,11 +435,14 @@ class PodcastController {
if (hardDelete) {
const audioFile = episode.audioFile
// TODO: this will trigger the watcher. should maybe handle this gracefully
await fs.remove(audioFile.metadata.path).then(() => {
Logger.info(`[PodcastController] Hard deleted episode file at "${audioFile.metadata.path}"`)
}).catch((error) => {
Logger.error(`[PodcastController] Failed to hard delete episode file at "${audioFile.metadata.path}"`, error)
})
await fs
.remove(audioFile.metadata.path)
.then(() => {
Logger.info(`[PodcastController] Hard deleted episode file at "${audioFile.metadata.path}"`)
})
.catch((error) => {
Logger.error(`[PodcastController] Failed to hard delete episode file at "${audioFile.metadata.path}"`, error)
})
}
// Remove episode from Podcast and library file
@ -369,6 +491,12 @@ class PodcastController {
res.json(libraryItem.toJSON())
}
/**
*
* @param {RequestWithUser} req
* @param {Response} res
* @param {NextFunction} next
*/
async middleware(req, res, next) {
const item = await Database.libraryItemModel.getOldById(req.params.id)
if (!item?.media) return res.sendStatus(404)
@ -383,10 +511,10 @@ class PodcastController {
}
if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[PodcastController] User attempted to delete without permission`, req.user.username)
Logger.warn(`[PodcastController] User "${req.user.username}" attempted to delete without permission`)
return res.sendStatus(403)
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
Logger.warn('[PodcastController] User attempted to update without permission', req.user.username)
Logger.warn(`[PodcastController] User "${req.user.username}" attempted to update without permission`)
return res.sendStatus(403)
}