mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
379 lines
10 KiB
JavaScript
379 lines
10 KiB
JavaScript
const { Request, Response } = require('express')
|
|
const Path = require('path')
|
|
|
|
const Logger = require('../Logger')
|
|
const SocketAuthority = require('../SocketAuthority')
|
|
const Database = require('../Database')
|
|
|
|
const fs = require('../libs/fsExtra')
|
|
|
|
class RssFeedManager {
|
|
constructor() {}
|
|
|
|
/**
|
|
* Remove invalid feeds (invalid if the entity does not exist)
|
|
*/
|
|
async init() {
|
|
const feeds = await Database.feedModel.findAll({
|
|
attributes: ['id', 'entityId', 'entityType', 'title'],
|
|
include: [
|
|
{
|
|
model: Database.libraryItemModel,
|
|
attributes: ['id']
|
|
},
|
|
{
|
|
model: Database.collectionModel,
|
|
attributes: ['id']
|
|
},
|
|
{
|
|
model: Database.seriesModel,
|
|
attributes: ['id']
|
|
}
|
|
]
|
|
})
|
|
|
|
const feedIdsToRemove = []
|
|
for (const feed of feeds) {
|
|
if (!feed.entity) {
|
|
Logger.error(`[RssFeedManager] Removing feed "${feed.title}". Entity not found`)
|
|
feedIdsToRemove.push(feed.id)
|
|
}
|
|
}
|
|
|
|
if (feedIdsToRemove.length) {
|
|
Logger.info(`[RssFeedManager] Removing ${feedIdsToRemove.length} invalid feeds`)
|
|
await Database.feedModel.destroy({
|
|
where: {
|
|
id: feedIdsToRemove
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find open feed for an entity (e.g. collection id, playlist id, library item id)
|
|
* @param {string} entityId
|
|
* @returns {Promise<import('../models/Feed')>}
|
|
*/
|
|
findFeedForEntityId(entityId) {
|
|
return Database.feedModel.findOne({
|
|
where: {
|
|
entityId
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} slug
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
checkExistsBySlug(slug) {
|
|
return Database.feedModel
|
|
.count({
|
|
where: {
|
|
slug
|
|
}
|
|
})
|
|
.then((count) => count > 0)
|
|
}
|
|
|
|
/**
|
|
* Feed requires update if the entity (or child entities) has been updated since the feed was last updated
|
|
*
|
|
* @param {import('../models/Feed')} feed
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async checkFeedRequiresUpdate(feed) {
|
|
if (feed.entityType === 'libraryItem') {
|
|
feed.entity = await feed.getEntity({
|
|
attributes: ['id', 'updatedAt', 'mediaId', 'mediaType']
|
|
})
|
|
|
|
let newEntityUpdatedAt = feed.entity.updatedAt
|
|
|
|
if (feed.entity.mediaType === 'podcast') {
|
|
const mostRecentPodcastEpisode = await Database.podcastEpisodeModel.findOne({
|
|
where: {
|
|
podcastId: feed.entity.mediaId
|
|
},
|
|
attributes: ['id', 'updatedAt'],
|
|
order: [['createdAt', 'DESC']]
|
|
})
|
|
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
|
|
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt
|
|
}
|
|
}
|
|
|
|
return newEntityUpdatedAt > feed.entityUpdatedAt
|
|
} else if (feed.entityType === 'collection' || feed.entityType === 'series') {
|
|
feed.entity = await feed.getEntity({
|
|
attributes: ['id', 'updatedAt'],
|
|
include: {
|
|
model: Database.bookModel,
|
|
attributes: ['id'],
|
|
through: {
|
|
attributes: []
|
|
},
|
|
include: {
|
|
model: Database.libraryItemModel,
|
|
attributes: ['id', 'updatedAt']
|
|
}
|
|
}
|
|
})
|
|
|
|
let newEntityUpdatedAt = feed.entity.updatedAt
|
|
|
|
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
|
|
if (book.libraryItem.updatedAt > mostRecent) {
|
|
return book.libraryItem.updatedAt
|
|
}
|
|
return mostRecent
|
|
}, 0)
|
|
|
|
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
|
|
newEntityUpdatedAt = mostRecentItemUpdatedAt
|
|
}
|
|
|
|
return newEntityUpdatedAt > feed.entityUpdatedAt
|
|
} else {
|
|
throw new Error('Invalid feed entity type')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET: /feed/:slug
|
|
*
|
|
* @param {Request} req
|
|
* @param {Response} res
|
|
*/
|
|
async getFeed(req, res) {
|
|
let feed = await Database.feedModel.findOne({
|
|
where: {
|
|
slug: req.params.slug
|
|
}
|
|
})
|
|
if (!feed) {
|
|
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
|
res.sendStatus(404)
|
|
return
|
|
}
|
|
|
|
const feedRequiresUpdate = await this.checkFeedRequiresUpdate(feed)
|
|
if (feedRequiresUpdate) {
|
|
Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
|
|
feed = await feed.updateFeedForEntity()
|
|
} else {
|
|
feed.feedEpisodes = await feed.getFeedEpisodes()
|
|
}
|
|
|
|
const xml = feed.buildXml(req.originalHostPrefix)
|
|
res.set('Content-Type', 'text/xml')
|
|
res.send(xml)
|
|
}
|
|
|
|
/**
|
|
* GET: /feed/:slug/item/:episodeId/*
|
|
*
|
|
* @param {Request} req
|
|
* @param {Response} res
|
|
*/
|
|
async getFeedItem(req, res) {
|
|
const feed = await Database.feedModel.findOne({
|
|
where: {
|
|
slug: req.params.slug
|
|
},
|
|
attributes: ['id', 'slug'],
|
|
include: {
|
|
model: Database.feedEpisodeModel,
|
|
attributes: ['id', 'filePath']
|
|
}
|
|
})
|
|
|
|
if (!feed) {
|
|
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
|
res.sendStatus(404)
|
|
return
|
|
}
|
|
const episodePath = feed.getEpisodePath(req.params.episodeId)
|
|
if (!episodePath) {
|
|
Logger.error(`[RssFeedManager] Feed episode not found ${req.params.episodeId}`)
|
|
res.sendStatus(404)
|
|
return
|
|
}
|
|
res.sendFile(episodePath)
|
|
}
|
|
|
|
/**
|
|
* GET: /feed/:slug/cover*
|
|
*
|
|
* @param {Request} req
|
|
* @param {Response} res
|
|
*/
|
|
async getFeedCover(req, res) {
|
|
const feed = await Database.feedModel.findOne({
|
|
where: {
|
|
slug: req.params.slug
|
|
},
|
|
attributes: ['coverPath']
|
|
})
|
|
if (!feed) {
|
|
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
|
|
res.sendStatus(404)
|
|
return
|
|
}
|
|
|
|
if (!feed.coverPath) {
|
|
res.sendStatus(404)
|
|
return
|
|
}
|
|
|
|
const extname = Path.extname(feed.coverPath).toLowerCase().slice(1)
|
|
res.type(`image/${extname}`)
|
|
const readStream = fs.createReadStream(feed.coverPath)
|
|
readStream.pipe(res)
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {*} options
|
|
* @returns {import('../models/Feed').FeedOptions}
|
|
*/
|
|
getFeedOptionsFromReqOptions(options) {
|
|
const metadataDetails = options.metadataDetails || {}
|
|
|
|
if (metadataDetails.preventIndexing !== false) {
|
|
metadataDetails.preventIndexing = true
|
|
}
|
|
|
|
return {
|
|
preventIndexing: metadataDetails.preventIndexing,
|
|
ownerName: metadataDetails.ownerName && typeof metadataDetails.ownerName === 'string' ? metadataDetails.ownerName : null,
|
|
ownerEmail: metadataDetails.ownerEmail && typeof metadataDetails.ownerEmail === 'string' ? metadataDetails.ownerEmail : null
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} userId
|
|
* @param {import('../models/LibraryItem')} libraryItem
|
|
* @param {*} options
|
|
* @returns {Promise<import('../models/Feed').FeedExpanded>}
|
|
*/
|
|
async openFeedForItem(userId, libraryItem, options) {
|
|
const serverAddress = options.serverAddress
|
|
const slug = options.slug
|
|
const feedOptions = this.getFeedOptionsFromReqOptions(options)
|
|
|
|
Logger.info(`[RssFeedManager] Creating RSS feed for item ${libraryItem.id} "${libraryItem.media.title}"`)
|
|
const feedExpanded = await Database.feedModel.createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
|
|
if (feedExpanded) {
|
|
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
|
|
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
|
|
}
|
|
return feedExpanded
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} userId
|
|
* @param {import('../models/Collection')} collectionExpanded
|
|
* @param {*} options
|
|
* @returns {Promise<import('../models/Feed').FeedExpanded>}
|
|
*/
|
|
async openFeedForCollection(userId, collectionExpanded, options) {
|
|
const serverAddress = options.serverAddress
|
|
const slug = options.slug
|
|
const feedOptions = this.getFeedOptionsFromReqOptions(options)
|
|
|
|
Logger.info(`[RssFeedManager] Creating RSS feed for collection "${collectionExpanded.name}"`)
|
|
const feedExpanded = await Database.feedModel.createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
|
|
if (feedExpanded) {
|
|
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
|
|
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
|
|
}
|
|
return feedExpanded
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} userId
|
|
* @param {import('../models/Series')} seriesExpanded
|
|
* @param {*} options
|
|
* @returns {Promise<import('../models/Feed').FeedExpanded>}
|
|
*/
|
|
async openFeedForSeries(userId, seriesExpanded, options) {
|
|
const serverAddress = options.serverAddress
|
|
const slug = options.slug
|
|
const feedOptions = this.getFeedOptionsFromReqOptions(options)
|
|
|
|
Logger.info(`[RssFeedManager] Creating RSS feed for series "${seriesExpanded.name}"`)
|
|
const feedExpanded = await Database.feedModel.createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
|
|
if (feedExpanded) {
|
|
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
|
|
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
|
|
}
|
|
return feedExpanded
|
|
}
|
|
|
|
/**
|
|
* Close Feed and emit Socket event
|
|
*
|
|
* @param {import('../models/Feed')} feed
|
|
* @returns {Promise<boolean>} - true if feed was closed
|
|
*/
|
|
async handleCloseFeed(feed) {
|
|
if (!feed) return false
|
|
const wasRemoved = await Database.feedModel.removeById(feed.id)
|
|
SocketAuthority.emitter('rss_feed_closed', feed.toOldJSONMinified())
|
|
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedURL}"`)
|
|
return wasRemoved
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} entityId
|
|
* @returns {Promise<boolean>} - true if feed was closed
|
|
*/
|
|
async closeFeedForEntityId(entityId) {
|
|
const feed = await Database.feedModel.findOne({
|
|
where: {
|
|
entityId
|
|
}
|
|
})
|
|
if (!feed) {
|
|
Logger.warn(`[RssFeedManager] closeFeedForEntityId: Feed not found for entity id ${entityId}`)
|
|
return false
|
|
}
|
|
return this.handleCloseFeed(feed)
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string[]} entityIds
|
|
*/
|
|
async closeFeedsForEntityIds(entityIds) {
|
|
const feeds = await Database.feedModel.findAll({
|
|
where: {
|
|
entityId: entityIds
|
|
}
|
|
})
|
|
for (const feed of feeds) {
|
|
await this.handleCloseFeed(feed)
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @returns {Promise<import('../models/Feed').FeedExpanded[]>}
|
|
*/
|
|
getFeeds() {
|
|
return Database.feedModel.findAll({
|
|
include: {
|
|
model: Database.feedEpisodeModel
|
|
}
|
|
})
|
|
}
|
|
}
|
|
module.exports = new RssFeedManager()
|