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} */ findFeedForEntityId(entityId) { return Database.feedModel.findOne({ where: { entityId } }) } /** * * @param {string} slug * @returns {Promise} */ 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} */ 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} */ 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} */ 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} */ 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} - 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} - 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} */ getFeeds() { return Database.feedModel.findAll({ include: { model: Database.feedEpisodeModel } }) } } module.exports = new RssFeedManager()