mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
305 lines
12 KiB
JavaScript
305 lines
12 KiB
JavaScript
const fs = require('fs-extra')
|
|
const cron = require('node-cron')
|
|
const axios = require('axios')
|
|
|
|
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
|
const Logger = require('../Logger')
|
|
|
|
const { downloadFile } = require('../utils/fileUtils')
|
|
const opmlParser = require('../utils/parsers/parseOPML')
|
|
const prober = require('../utils/prober')
|
|
const LibraryFile = require('../objects/files/LibraryFile')
|
|
const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload')
|
|
const PodcastEpisode = require('../objects/entities/PodcastEpisode')
|
|
const AudioFile = require('../objects/files/AudioFile')
|
|
|
|
class PodcastManager {
|
|
constructor(db, watcher, emitter) {
|
|
this.db = db
|
|
this.watcher = watcher
|
|
this.emitter = emitter
|
|
|
|
this.downloadQueue = []
|
|
this.currentDownload = null
|
|
|
|
this.episodeScheduleTask = null
|
|
this.failedCheckMap = {}
|
|
}
|
|
|
|
get serverSettings() {
|
|
return this.db.serverSettings || {}
|
|
}
|
|
|
|
init() {
|
|
var podcastsWithAutoDownload = this.db.libraryItems.some(li => li.mediaType === 'podcast' && li.media.autoDownloadEpisodes)
|
|
if (podcastsWithAutoDownload) {
|
|
this.schedulePodcastEpisodeCron()
|
|
}
|
|
}
|
|
|
|
getEpisodeDownloadsInQueue(libraryItemId) {
|
|
return this.downloadQueue.filter(d => d.libraryItemId === libraryItemId)
|
|
}
|
|
|
|
clearDownloadQueue(libraryItemId = null) {
|
|
if (!this.downloadQueue.length) return
|
|
|
|
if (!libraryItemId) {
|
|
Logger.info(`[PodcastManager] Clearing all downloads in queue (${this.downloadQueue.length})`)
|
|
this.downloadQueue = []
|
|
} else {
|
|
var itemDownloads = this.getEpisodeDownloadsInQueue(libraryItemId)
|
|
Logger.info(`[PodcastManager] Clearing downloads in queue for item "${libraryItemId}" (${itemDownloads.length})`)
|
|
this.downloadQueue = this.downloadQueue.filter(d => d.libraryItemId !== libraryItemId)
|
|
}
|
|
}
|
|
|
|
async downloadPodcastEpisodes(libraryItem, episodesToDownload) {
|
|
var index = libraryItem.media.episodes.length + 1
|
|
episodesToDownload.forEach((ep) => {
|
|
var newPe = new PodcastEpisode()
|
|
newPe.setData(ep, index++)
|
|
newPe.libraryItemId = libraryItem.id
|
|
var newPeDl = new PodcastEpisodeDownload()
|
|
newPeDl.setData(newPe, libraryItem)
|
|
this.startPodcastEpisodeDownload(newPeDl)
|
|
})
|
|
}
|
|
|
|
async startPodcastEpisodeDownload(podcastEpisodeDownload) {
|
|
if (this.currentDownload) {
|
|
this.downloadQueue.push(podcastEpisodeDownload)
|
|
this.emitter('episode_download_queued', podcastEpisodeDownload.toJSONForClient())
|
|
return
|
|
}
|
|
|
|
this.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient())
|
|
this.currentDownload = podcastEpisodeDownload
|
|
|
|
// Ignores all added files to this dir
|
|
this.watcher.addIgnoreDir(this.currentDownload.libraryItem.path)
|
|
|
|
var success = await downloadFile(this.currentDownload.url, this.currentDownload.targetPath).then(() => true).catch((error) => {
|
|
Logger.error(`[PodcastManager] Podcast Episode download failed`, error)
|
|
return false
|
|
})
|
|
if (success) {
|
|
success = await this.scanAddPodcastEpisodeAudioFile()
|
|
if (!success) {
|
|
await fs.remove(this.currentDownload.targetPath)
|
|
this.currentDownload.setFinished(false)
|
|
} else {
|
|
Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`)
|
|
this.currentDownload.setFinished(true)
|
|
}
|
|
} else {
|
|
this.currentDownload.setFinished(false)
|
|
}
|
|
|
|
this.emitter('episode_download_finished', this.currentDownload.toJSONForClient())
|
|
|
|
this.watcher.removeIgnoreDir(this.currentDownload.libraryItem.path)
|
|
this.currentDownload = null
|
|
if (this.downloadQueue.length) {
|
|
this.startPodcastEpisodeDownload(this.downloadQueue.shift())
|
|
}
|
|
}
|
|
|
|
async scanAddPodcastEpisodeAudioFile() {
|
|
var libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath)
|
|
|
|
// TODO: Set meta tags on new audio file
|
|
|
|
var audioFile = await this.probeAudioFile(libraryFile)
|
|
if (!audioFile) {
|
|
return false
|
|
}
|
|
|
|
var libraryItem = this.db.libraryItems.find(li => li.id === this.currentDownload.libraryItem.id)
|
|
if (!libraryItem) {
|
|
Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`)
|
|
return false
|
|
}
|
|
|
|
var podcastEpisode = this.currentDownload.podcastEpisode
|
|
podcastEpisode.audioFile = audioFile
|
|
libraryItem.media.addPodcastEpisode(podcastEpisode)
|
|
if (libraryItem.isInvalid) {
|
|
// First episode added to an empty podcast
|
|
libraryItem.isInvalid = false
|
|
}
|
|
libraryItem.libraryFiles.push(libraryFile)
|
|
libraryItem.updatedAt = Date.now()
|
|
await this.db.updateLibraryItem(libraryItem)
|
|
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
|
return true
|
|
}
|
|
|
|
async getLibraryFile(path, relPath) {
|
|
var newLibFile = new LibraryFile()
|
|
await newLibFile.setDataFromPath(path, relPath)
|
|
return newLibFile
|
|
}
|
|
|
|
async probeAudioFile(libraryFile) {
|
|
var path = libraryFile.metadata.path
|
|
var mediaProbeData = await prober.probe(path)
|
|
if (mediaProbeData.error) {
|
|
Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error)
|
|
return false
|
|
}
|
|
var newAudioFile = new AudioFile()
|
|
newAudioFile.setDataFromProbe(libraryFile, mediaProbeData)
|
|
return newAudioFile
|
|
}
|
|
|
|
schedulePodcastEpisodeCron() {
|
|
try {
|
|
Logger.debug(`[PodcastManager] Scheduled podcast episode check cron "${this.serverSettings.podcastEpisodeSchedule}"`)
|
|
this.episodeScheduleTask = cron.schedule(this.serverSettings.podcastEpisodeSchedule, () => {
|
|
Logger.debug(`[PodcastManager] Running cron`)
|
|
this.checkForNewEpisodes()
|
|
})
|
|
} catch (error) {
|
|
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
|
}
|
|
}
|
|
|
|
cancelCron() {
|
|
Logger.debug(`[PodcastManager] Canceled new podcast episode check cron`)
|
|
if (this.episodeScheduleTask) {
|
|
this.episodeScheduleTask.destroy()
|
|
this.episodeScheduleTask = null
|
|
}
|
|
}
|
|
|
|
async checkForNewEpisodes() {
|
|
var podcastsWithAutoDownload = this.db.libraryItems.filter(li => li.mediaType === 'podcast' && li.media.autoDownloadEpisodes)
|
|
if (!podcastsWithAutoDownload.length) {
|
|
Logger.info(`[PodcastManager] checkForNewEpisodes - No podcasts with auto download set`)
|
|
this.cancelCron()
|
|
return
|
|
}
|
|
Logger.debug(`[PodcastManager] checkForNewEpisodes - Checking ${podcastsWithAutoDownload.length} Podcasts`)
|
|
|
|
for (const libraryItem of podcastsWithAutoDownload) {
|
|
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
|
const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished
|
|
Logger.info(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`)
|
|
|
|
// Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate
|
|
// lastEpisodeCheckDate will be the current time when adding a new podcast
|
|
const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate
|
|
Logger.debug(`[PodcastManager] checkForNewEpisodes: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`)
|
|
|
|
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter)
|
|
Logger.debug(`[PodcastManager] checkForNewEpisodes checked result ${newEpisodes ? newEpisodes.length : 'N/A'}`)
|
|
|
|
if (!newEpisodes) { // Failed
|
|
// Allow up to 3 failed attempts before disabling auto download
|
|
if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0
|
|
this.failedCheckMap[libraryItem.id]++
|
|
if (this.failedCheckMap[libraryItem.id] > 2) {
|
|
Logger.error(`[PodcastManager] checkForNewEpisodes 3 failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`)
|
|
libraryItem.media.autoDownloadEpisodes = false
|
|
delete this.failedCheckMap[libraryItem.id]
|
|
} else {
|
|
Logger.warn(`[PodcastManager] checkForNewEpisodes ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`)
|
|
}
|
|
} else if (newEpisodes.length) {
|
|
delete this.failedCheckMap[libraryItem.id]
|
|
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
|
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
|
|
} else {
|
|
delete this.failedCheckMap[libraryItem.id]
|
|
Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`)
|
|
}
|
|
|
|
libraryItem.media.lastEpisodeCheck = Date.now()
|
|
libraryItem.updatedAt = Date.now()
|
|
await this.db.updateLibraryItem(libraryItem)
|
|
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
|
}
|
|
}
|
|
|
|
async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter) {
|
|
if (!podcastLibraryItem.media.metadata.feedUrl) {
|
|
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`)
|
|
return false
|
|
}
|
|
var feed = await this.getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl)
|
|
if (!feed || !feed.episodes) {
|
|
Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed)
|
|
return false
|
|
}
|
|
|
|
// Filter new and not already has
|
|
var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > dateToCheckForEpisodesAfter && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url))
|
|
// Max new episodes for safety = 3
|
|
newEpisodes = newEpisodes.slice(0, 3)
|
|
return newEpisodes
|
|
}
|
|
|
|
async checkAndDownloadNewEpisodes(libraryItem) {
|
|
const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0)
|
|
Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`)
|
|
var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck)
|
|
if (newEpisodes.length) {
|
|
Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`)
|
|
this.downloadPodcastEpisodes(libraryItem, newEpisodes)
|
|
} else {
|
|
Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.metadata.title}"`)
|
|
}
|
|
|
|
libraryItem.media.lastEpisodeCheck = Date.now()
|
|
libraryItem.updatedAt = Date.now()
|
|
await this.db.updateLibraryItem(libraryItem)
|
|
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
|
|
|
return newEpisodes
|
|
}
|
|
|
|
getPodcastFeed(feedUrl, excludeEpisodeMetadata = false) {
|
|
Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}"`)
|
|
return axios.get(feedUrl, { timeout: 5000 }).then(async (data) => {
|
|
if (!data || !data.data) {
|
|
Logger.error('Invalid podcast feed request response')
|
|
return false
|
|
}
|
|
Logger.debug(`[PodcastManager] getPodcastFeed for "${feedUrl}" success - parsing xml`)
|
|
var payload = await parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata)
|
|
if (!payload) {
|
|
return false
|
|
}
|
|
return payload.podcast
|
|
}).catch((error) => {
|
|
console.error('Failed', error)
|
|
return false
|
|
})
|
|
}
|
|
|
|
async getOPMLFeeds(opmlText) {
|
|
var extractedFeeds = opmlParser.parse(opmlText)
|
|
if (!extractedFeeds || !extractedFeeds.length) {
|
|
Logger.error('[PodcastManager] getOPMLFeeds: No RSS feeds found in OPML')
|
|
return {
|
|
error: 'No RSS feeds found in OPML'
|
|
}
|
|
}
|
|
|
|
var rssFeedData = []
|
|
|
|
for (let feed of extractedFeeds) {
|
|
var feedData = await this.getPodcastFeed(feed.feedUrl, true)
|
|
if (feedData) {
|
|
feedData.metadata.feedUrl = feed.feedUrl
|
|
rssFeedData.push(feedData)
|
|
}
|
|
}
|
|
|
|
return {
|
|
feeds: rssFeedData
|
|
}
|
|
}
|
|
}
|
|
module.exports = PodcastManager |