diff --git a/server/controllers/RSSFeedController.js b/server/controllers/RSSFeedController.js index fc151a1d..f813dc89 100644 --- a/server/controllers/RSSFeedController.js +++ b/server/controllers/RSSFeedController.js @@ -41,6 +41,40 @@ class RSSFeedController { }) } + // POST: api/feeds/collection/:collectionId/open + async openRSSFeedForCollection(req, res) { + const options = req.body || {} + + const collection = this.db.collections.find(li => li.id === req.params.collectionId) + if (!collection) return res.sendStatus(404) + + // Check request body options exist + if (!options.serverAddress || !options.slug) { + Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) + return res.status(400).send('Invalid request body') + } + + // Check that this slug is not being used for another feed (slug will also be the Feed id) + if (this.rssFeedManager.feeds[options.slug]) { + Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) + return res.status(400).send('Slug already in use') + } + + const collectionExpanded = collection.toJSONExpanded(this.db.libraryItems) + const collectionItemsWithTracks = collectionExpanded.books.filter(li => li.media.tracks.length) + + // Check collection has audio tracks + if (!collectionItemsWithTracks.length) { + Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`) + return res.status(400).send('Collection has no audio tracks') + } + + const feed = await this.rssFeedManager.openFeedForCollection(req.user, collectionExpanded, req.body) + res.json({ + feed: feed.toJSONMinified() + }) + } + // POST: api/feeds/:id/close async closeRSSFeed(req, res) { await this.rssFeedManager.closeRssFeed(req.params.id) diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index fc87c309..1876816f 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -51,6 +51,18 @@ class RssFeedManager { feed.updateFromItem(libraryItem) await this.db.updateEntity('feed', feed) } + } else if (feed.entityType === 'collection') { + // TODO: Also trigger an update if any item in the collection was updated + const collection = this.db.collections.find(c => c.id === feed.entityId) + if (collection) { + const collectionExpanded = collection.toJSONExpanded(this.db.libraryItems) + if (!feed.entityUpdatedAt || collection.lastUpdate > feed.entityUpdatedAt) { + Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`) + + feed.updateFromCollection(collectionExpanded) + await this.db.updateEntity('feed', feed) + } + } } const xml = feed.buildXml() @@ -107,10 +119,18 @@ class RssFeedManager { return feed } - closeFeedForItem(libraryItemId) { - const feed = this.findFeedForItem(libraryItemId) - if (!feed) return - return this.closeRssFeed(feed.id) + async openFeedForCollection(user, collectionExpanded, options) { + const serverAddress = options.serverAddress + const slug = options.slug + + const feed = new Feed() + feed.setFromCollection(user.id, slug, collectionExpanded, serverAddress) + this.feeds[feed.id] = feed + + Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) + await this.db.insertEntity('feed', feed) + SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) + return feed } async closeRssFeed(id) { diff --git a/server/objects/Collection.js b/server/objects/Collection.js index 813ced21..4f97a802 100644 --- a/server/objects/Collection.js +++ b/server/objects/Collection.js @@ -1,4 +1,3 @@ -const Logger = require('../Logger') const { getId } = require('../utils/index') class Collection { @@ -46,6 +45,18 @@ class Collection { return json } + // Expanded and filtered out items not accessible to user + toJSONExpandedForUser(user, libraryItems) { + const json = this.toJSON() + json.books = json.books.map(libraryItemId => { + const libraryItem = libraryItems.find(li => li.id === libraryItemId) + return libraryItem ? libraryItem.toJSONExpanded() : null + }).filter(li => { + return li && user.checkCanAccessLibraryItem(li) + }) + return json + } + construct(collection) { this.id = collection.id this.libraryId = collection.libraryId diff --git a/server/objects/Feed.js b/server/objects/Feed.js index bbc342b6..4c42ece5 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -129,6 +129,7 @@ class Feed { const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName this.entityUpdatedAt = libraryItem.updatedAt + this.coverPath = media.coverPath || null this.meta.title = mediaMetadata.title this.meta.description = mediaMetadata.description @@ -155,6 +156,72 @@ class Feed { this.xml = null } + setFromCollection(userId, slug, collectionExpanded, serverAddress) { + const feedUrl = `${serverAddress}/feed/${slug}` + + const itemsWithTracks = collectionExpanded.books.filter(libraryItem => libraryItem.media.tracks.length) + const firstItemWithCover = itemsWithTracks.find(item => item.media.coverPath) + + this.id = slug + this.slug = slug + this.userId = userId + this.entityType = 'collection' + this.entityId = collectionExpanded.id + this.entityUpdatedAt = collectionExpanded.lastUpdate + this.coverPath = firstItemWithCover?.coverPath || null + this.serverAddress = serverAddress + this.feedUrl = feedUrl + + this.meta = new FeedMeta() + this.meta.title = collectionExpanded.name + this.meta.description = collectionExpanded.description || '' + this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) + this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png` + this.meta.feedUrl = feedUrl + this.meta.link = `${serverAddress}/collection/${collectionExpanded.id}` + this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit + + this.episodes = [] + + itemsWithTracks.forEach((item, index) => { + item.media.tracks.forEach((audioTrack) => { + const feedEpisode = new FeedEpisode() + feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, index) + this.episodes.push(feedEpisode) + }) + }) + + this.createdAt = Date.now() + this.updatedAt = Date.now() + } + + updateFromCollection(collectionExpanded) { + const itemsWithTracks = collectionExpanded.books.filter(libraryItem => libraryItem.media.tracks.length) + const firstItemWithCover = itemsWithTracks.find(item => item.media.coverPath) + + this.entityUpdatedAt = collectionExpanded.lastUpdate + this.coverPath = firstItemWithCover?.coverPath || null + + this.meta.title = collectionExpanded.name + this.meta.description = collectionExpanded.description || '' + this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) + this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png` + this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit + + this.episodes = [] + + itemsWithTracks.forEach((item, index) => { + item.media.tracks.forEach((audioTrack) => { + const feedEpisode = new FeedEpisode() + feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, index) + this.episodes.push(feedEpisode) + }) + }) + + this.updatedAt = Date.now() + this.xml = null + } + buildXml() { if (this.xml) return this.xml @@ -165,5 +232,16 @@ class Feed { this.xml = rssfeed.xml() return this.xml } + + getAuthorsStringFromLibraryItems(libraryItems) { + let itemAuthors = [] + libraryItems.forEach((item) => itemAuthors.push(...item.media.metadata.authors.map(au => au.name))) + itemAuthors = [...new Set(itemAuthors)] // Filter out dupes + let author = itemAuthors.slice(0, 3).join(', ') + if (itemAuthors.length > 3) { + author += ' & more' + } + return author + } } module.exports = Feed \ No newline at end of file diff --git a/server/objects/FeedEpisode.js b/server/objects/FeedEpisode.js index 77acb111..8891aa07 100644 --- a/server/objects/FeedEpisode.js +++ b/server/objects/FeedEpisode.js @@ -83,9 +83,15 @@ class FeedEpisode { this.fullPath = episode.audioFile.metadata.path } - setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta) { + setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, additionalOffset = 0) { // Example: Fri, 04 Feb 2015 00:00:00 GMT - const timeOffset = isNaN(audioTrack.index) ? 0 : (Number(audioTrack.index) * 1000) // Offset pubdate to ensure correct order + let timeOffset = isNaN(audioTrack.index) ? 0 : (Number(audioTrack.index) * 1000) // Offset pubdate to ensure correct order + + // Additional offset can be used for collections/series + if (additionalOffset && !isNaN(additionalOffset)) { + timeOffset += Number(additionalOffset) * 1000 + } + // e.g. Track 1 will have a pub date before Track 2 const audiobookPubDate = date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]') diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 05fc135e..59ba0dec 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -267,6 +267,7 @@ class ApiRouter { // RSS Feed Routes (Admin and up) // this.router.post('/feeds/item/:itemId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForItem.bind(this)) + this.router.post('/feeds/collection/:collectionId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForCollection.bind(this)) this.router.post('/feeds/:id/close', RSSFeedController.middleware.bind(this), RSSFeedController.closeRSSFeed.bind(this)) //