2022-03-19 16:13:10 +01:00
|
|
|
const axios = require('axios')
|
|
|
|
const fs = require('fs-extra')
|
2022-05-14 00:13:58 +02:00
|
|
|
const Path = require('path')
|
2022-03-19 16:13:10 +01:00
|
|
|
const Logger = require('../Logger')
|
|
|
|
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
|
|
|
const LibraryItem = require('../objects/LibraryItem')
|
2022-05-14 00:13:58 +02:00
|
|
|
const { getFileTimestampsWithIno, sanitizeFilename } = require('../utils/fileUtils')
|
2022-03-22 01:24:38 +01:00
|
|
|
const filePerms = require('../utils/filePerms')
|
2022-03-19 16:13:10 +01:00
|
|
|
|
|
|
|
class PodcastController {
|
|
|
|
|
|
|
|
async create(req, res) {
|
2022-04-30 01:29:40 +02:00
|
|
|
if (!req.user.isAdminOrUp) {
|
|
|
|
Logger.error(`[PodcastController] Non-admin user attempted to create podcast`, req.user)
|
2022-03-19 16:13:10 +01:00
|
|
|
return res.sendStatus(500)
|
|
|
|
}
|
|
|
|
const payload = req.body
|
|
|
|
|
2022-03-22 01:24:38 +01:00
|
|
|
const library = this.db.libraries.find(lib => lib.id === payload.libraryId)
|
|
|
|
if (!library) {
|
|
|
|
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
|
|
|
|
return res.status(400).send('Library not found')
|
|
|
|
}
|
|
|
|
|
|
|
|
const folder = library.folders.find(fold => fold.id === payload.folderId)
|
|
|
|
if (!folder) {
|
|
|
|
Logger.error(`[PodcastController] Create: Folder not found "${payload.folderId}"`)
|
|
|
|
return res.status(400).send('Folder not found')
|
|
|
|
}
|
|
|
|
|
|
|
|
var podcastPath = payload.path.replace(/\\/g, '/')
|
|
|
|
if (await fs.pathExists(podcastPath)) {
|
2022-04-24 02:41:06 +02:00
|
|
|
Logger.error(`[PodcastController] Podcast folder already exists "${podcastPath}"`)
|
|
|
|
return res.status(400).send('Podcast already exists')
|
2022-03-19 16:13:10 +01:00
|
|
|
}
|
|
|
|
|
2022-03-22 01:24:38 +01:00
|
|
|
var success = await fs.ensureDir(podcastPath).then(() => true).catch((error) => {
|
|
|
|
Logger.error(`[PodcastController] Failed to ensure podcast dir "${podcastPath}"`, error)
|
2022-03-19 16:13:10 +01:00
|
|
|
return false
|
|
|
|
})
|
|
|
|
if (!success) return res.status(400).send('Invalid podcast path')
|
2022-03-22 01:24:38 +01:00
|
|
|
await filePerms.setDefault(podcastPath)
|
|
|
|
|
|
|
|
var libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
|
|
|
|
|
|
|
var relPath = payload.path.replace(folder.fullPath, '')
|
|
|
|
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
2022-03-19 16:13:10 +01:00
|
|
|
|
2022-03-22 01:24:38 +01:00
|
|
|
const libraryItemPayload = {
|
|
|
|
path: podcastPath,
|
|
|
|
relPath,
|
|
|
|
folderId: payload.folderId,
|
|
|
|
libraryId: payload.libraryId,
|
|
|
|
ino: libraryItemFolderStats.ino,
|
|
|
|
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
|
|
|
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
|
|
|
birthtimeMs: libraryItemFolderStats.birthtimeMs || 0,
|
|
|
|
media: payload.media
|
2022-03-19 16:13:10 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
var libraryItem = new LibraryItem()
|
2022-03-22 01:24:38 +01:00
|
|
|
libraryItem.setData('podcast', libraryItemPayload)
|
|
|
|
|
|
|
|
// Download and save cover image
|
|
|
|
if (payload.media.metadata.imageUrl) {
|
2022-03-27 00:23:33 +01:00
|
|
|
// TODO: Scan cover image to library files
|
2022-04-24 02:41:06 +02:00
|
|
|
// Podcast cover will always go into library item folder
|
|
|
|
var coverResponse = await this.coverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true)
|
2022-03-22 01:24:38 +01:00
|
|
|
if (coverResponse) {
|
|
|
|
if (coverResponse.error) {
|
|
|
|
Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`)
|
|
|
|
} else if (coverResponse.cover) {
|
|
|
|
libraryItem.media.coverPath = coverResponse.cover
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-03-19 16:13:10 +01:00
|
|
|
|
|
|
|
await this.db.insertLibraryItem(libraryItem)
|
|
|
|
this.emitter('item_added', libraryItem.toJSONExpanded())
|
|
|
|
|
|
|
|
res.json(libraryItem.toJSONExpanded())
|
2022-03-22 01:24:38 +01:00
|
|
|
|
|
|
|
if (payload.episodesToDownload && payload.episodesToDownload.length) {
|
|
|
|
Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`)
|
|
|
|
this.podcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload)
|
|
|
|
}
|
2022-03-27 01:58:59 +01:00
|
|
|
|
|
|
|
// Turn on podcast auto download cron if not already on
|
|
|
|
if (libraryItem.media.autoDownloadEpisodes && !this.podcastManager.episodeScheduleTask) {
|
|
|
|
this.podcastManager.schedulePodcastEpisodeCron()
|
|
|
|
}
|
2022-03-19 16:13:10 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
getPodcastFeed(req, res) {
|
|
|
|
var url = req.body.rssFeed
|
|
|
|
if (!url) {
|
|
|
|
return res.status(400).send('Bad request')
|
|
|
|
}
|
2022-04-13 23:55:48 +02:00
|
|
|
var includeRaw = req.query.raw == 1 // Include raw json
|
2022-03-19 16:13:10 +01:00
|
|
|
|
|
|
|
axios.get(url).then(async (data) => {
|
|
|
|
if (!data || !data.data) {
|
|
|
|
Logger.error('Invalid podcast feed request response')
|
|
|
|
return res.status(500).send('Bad response from feed request')
|
|
|
|
}
|
2022-06-12 08:17:22 +02:00
|
|
|
Logger.debug(`[PodcastController] Podcast feed size ${(data.data.length / 1024 / 1024).toFixed(2)}MB`)
|
2022-05-29 18:46:45 +02:00
|
|
|
var payload = await parsePodcastRssFeedXml(data.data, false, includeRaw)
|
2022-04-13 23:55:48 +02:00
|
|
|
if (!payload) {
|
2022-03-19 16:13:10 +01:00
|
|
|
return res.status(500).send('Invalid podcast RSS feed')
|
|
|
|
}
|
2022-05-14 00:13:58 +02:00
|
|
|
|
2022-05-28 00:50:56 +02:00
|
|
|
// RSS feed may be a private RSS feed
|
|
|
|
payload.podcast.metadata.feedUrl = url
|
2022-05-14 00:13:58 +02:00
|
|
|
|
2022-04-13 23:55:48 +02:00
|
|
|
res.json(payload)
|
2022-03-19 16:13:10 +01:00
|
|
|
}).catch((error) => {
|
|
|
|
console.error('Failed', error)
|
|
|
|
res.status(500).send(error)
|
|
|
|
})
|
|
|
|
}
|
2022-03-27 01:58:59 +01:00
|
|
|
|
2022-05-29 18:46:45 +02:00
|
|
|
async getOPMLFeeds(req, res) {
|
|
|
|
if (!req.body.opmlText) {
|
|
|
|
return res.sendStatus(400)
|
|
|
|
}
|
|
|
|
|
|
|
|
const rssFeedsData = await this.podcastManager.getOPMLFeeds(req.body.opmlText)
|
|
|
|
res.json(rssFeedsData)
|
|
|
|
}
|
|
|
|
|
2022-03-27 01:58:59 +01:00
|
|
|
async checkNewEpisodes(req, res) {
|
2022-04-30 01:29:40 +02:00
|
|
|
if (!req.user.isAdminOrUp) {
|
|
|
|
Logger.error(`[PodcastController] Non-admin user attempted to check/download episodes`, req.user)
|
|
|
|
return res.sendStatus(500)
|
|
|
|
}
|
|
|
|
|
2022-05-02 21:41:59 +02:00
|
|
|
var libraryItem = req.libraryItem
|
2022-03-27 01:58:59 +01:00
|
|
|
if (!libraryItem.media.metadata.feedUrl) {
|
|
|
|
Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`)
|
|
|
|
return res.status(500).send('Podcast has no rss feed url')
|
|
|
|
}
|
|
|
|
|
2022-04-29 23:42:40 +02:00
|
|
|
var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem)
|
2022-03-27 01:58:59 +01:00
|
|
|
res.json({
|
|
|
|
episodes: newEpisodes || []
|
|
|
|
})
|
|
|
|
}
|
2022-03-27 22:37:04 +02:00
|
|
|
|
2022-04-24 02:41:06 +02:00
|
|
|
clearEpisodeDownloadQueue(req, res) {
|
2022-04-30 01:29:40 +02:00
|
|
|
if (!req.user.isAdminOrUp) {
|
|
|
|
Logger.error(`[PodcastController] Non-admin user attempting to clear download queue "${req.user.username}"`)
|
2022-04-24 02:41:06 +02:00
|
|
|
return res.sendStatus(500)
|
|
|
|
}
|
|
|
|
this.podcastManager.clearDownloadQueue(req.params.id)
|
|
|
|
res.sendStatus(200)
|
|
|
|
}
|
|
|
|
|
|
|
|
getEpisodeDownloads(req, res) {
|
2022-05-02 21:41:59 +02:00
|
|
|
var libraryItem = req.libraryItem
|
|
|
|
|
2022-04-24 02:41:06 +02:00
|
|
|
var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id)
|
|
|
|
res.json({
|
|
|
|
downloads: downloadsInQueue.map(d => d.toJSONForClient())
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-03-27 22:37:04 +02:00
|
|
|
async downloadEpisodes(req, res) {
|
2022-04-30 01:29:40 +02:00
|
|
|
if (!req.user.isAdminOrUp) {
|
|
|
|
Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user)
|
|
|
|
return res.sendStatus(500)
|
|
|
|
}
|
2022-05-02 21:41:59 +02:00
|
|
|
var libraryItem = req.libraryItem
|
2022-03-27 22:37:04 +02:00
|
|
|
|
|
|
|
var episodes = req.body
|
|
|
|
if (!episodes || !episodes.length) {
|
|
|
|
return res.sendStatus(400)
|
|
|
|
}
|
|
|
|
|
|
|
|
this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes)
|
|
|
|
res.sendStatus(200)
|
|
|
|
}
|
|
|
|
|
2022-05-02 21:41:59 +02:00
|
|
|
async updateEpisode(req, res) {
|
|
|
|
var libraryItem = req.libraryItem
|
|
|
|
|
2022-03-27 22:37:04 +02:00
|
|
|
var episodeId = req.params.episodeId
|
|
|
|
if (!libraryItem.media.checkHasEpisode(episodeId)) {
|
|
|
|
return res.status(500).send('Episode not found')
|
|
|
|
}
|
|
|
|
|
|
|
|
var wasUpdated = libraryItem.media.updateEpisode(episodeId, req.body)
|
|
|
|
if (wasUpdated) {
|
2022-03-27 22:46:57 +02:00
|
|
|
await this.db.updateLibraryItem(libraryItem)
|
2022-03-27 22:37:04 +02:00
|
|
|
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
|
|
|
}
|
|
|
|
|
|
|
|
res.json(libraryItem.toJSONExpanded())
|
|
|
|
}
|
2022-05-02 21:41:59 +02:00
|
|
|
|
2022-05-25 01:38:25 +02:00
|
|
|
// DELETE: api/podcasts/:id/episode/:episodeId
|
|
|
|
async removeEpisode(req, res) {
|
|
|
|
var episodeId = req.params.episodeId
|
|
|
|
var libraryItem = req.libraryItem
|
|
|
|
var hardDelete = req.query.hard === '1'
|
|
|
|
|
|
|
|
var 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)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (hardDelete) {
|
|
|
|
var 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)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2022-06-02 00:45:52 +02:00
|
|
|
// Remove episode from Podcast and library file
|
|
|
|
const episodeRemoved = libraryItem.media.removeEpisode(episodeId)
|
|
|
|
if (episodeRemoved && episodeRemoved.audioFile) {
|
|
|
|
libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino)
|
|
|
|
}
|
2022-05-25 01:38:25 +02:00
|
|
|
|
|
|
|
await this.db.updateLibraryItem(libraryItem)
|
|
|
|
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
|
|
|
res.json(libraryItem.toJSON())
|
|
|
|
}
|
|
|
|
|
2022-05-02 21:41:59 +02:00
|
|
|
middleware(req, res, next) {
|
|
|
|
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
|
|
|
if (!item || !item.media) return res.sendStatus(404)
|
|
|
|
|
|
|
|
if (!item.isPodcast) {
|
|
|
|
return res.sendStatus(500)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check user can access this library item
|
2022-05-28 23:53:03 +02:00
|
|
|
if (!req.user.checkCanAccessLibraryItem(item)) {
|
2022-05-02 21:41:59 +02:00
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (req.method == 'DELETE' && !req.user.canDelete) {
|
|
|
|
Logger.warn(`[PodcastController] User attempted to delete without permission`, req.user.username)
|
|
|
|
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)
|
|
|
|
return res.sendStatus(403)
|
|
|
|
}
|
|
|
|
|
|
|
|
req.libraryItem = item
|
|
|
|
next()
|
|
|
|
}
|
2022-03-19 16:13:10 +01:00
|
|
|
}
|
|
|
|
module.exports = new PodcastController()
|