From fbbceaa6424ccd8c1ac759453d8ff8ecf81aadcb Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 7 Jun 2022 18:29:43 -0500 Subject: [PATCH] Add:Persist RSS feeds in db #696, Update:RSS feed data model --- .../components/modals/rssfeed/ViewModal.vue | 1 - client/pages/item/_id/index.vue | 4 +- server/Db.js | 29 ++-- server/Server.js | 7 +- server/controllers/LibraryItemController.js | 4 +- server/managers/RssFeedManager.js | 156 ++++++------------ server/objects/Feed.js | 118 +++++++++++++ server/objects/FeedEpisode.js | 130 +++++++++++++++ server/objects/FeedMeta.js | 47 ++++++ 9 files changed, 366 insertions(+), 130 deletions(-) create mode 100644 server/objects/Feed.js create mode 100644 server/objects/FeedEpisode.js create mode 100644 server/objects/FeedMeta.js diff --git a/client/components/modals/rssfeed/ViewModal.vue b/client/components/modals/rssfeed/ViewModal.vue index c4a8c4ab..aa018e82 100644 --- a/client/components/modals/rssfeed/ViewModal.vue +++ b/client/components/modals/rssfeed/ViewModal.vue @@ -27,7 +27,6 @@

Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.

-

Note: RSS feed URLs are not authenticated

Close RSS Feed Open RSS Feed diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index bc80a0f7..8128b798 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -534,13 +534,13 @@ export default { } }, rssFeedOpen(data) { - if (data.libraryItemId === this.libraryItemId) { + if (data.entityId === this.libraryItemId) { console.log('RSS Feed Opened', data) this.rssFeedUrl = data.feedUrl } }, rssFeedClosed(data) { - if (data.libraryItemId === this.libraryItemId) { + if (data.entityId === this.libraryItemId) { console.log('RSS Feed Closed', data) this.rssFeedUrl = null } diff --git a/server/Db.js b/server/Db.js index 1a2937af..37e54eb0 100644 --- a/server/Db.js +++ b/server/Db.js @@ -11,6 +11,7 @@ const Author = require('./objects/entities/Author') const Series = require('./objects/entities/Series') const ServerSettings = require('./objects/settings/ServerSettings') const PlaybackSession = require('./objects/PlaybackSession') +const Feed = require('./objects/Feed') class Db { constructor() { @@ -22,6 +23,7 @@ class Db { this.CollectionsPath = Path.join(global.ConfigPath, 'collections') this.AuthorsPath = Path.join(global.ConfigPath, 'authors') this.SeriesPath = Path.join(global.ConfigPath, 'series') + this.FeedsPath = Path.join(global.ConfigPath, 'feeds') this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath) this.usersDb = new njodb.Database(this.UsersPath) @@ -31,6 +33,7 @@ class Db { this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 }) this.authorsDb = new njodb.Database(this.AuthorsPath) this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 }) + this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2 }) this.libraryItems = [] this.users = [] @@ -59,6 +62,7 @@ class Db { else if (entityName === 'collection') return this.collectionsDb else if (entityName === 'author') return this.authorsDb else if (entityName === 'series') return this.seriesDb + else if (entityName === 'feed') return this.feedsDb return null } @@ -71,6 +75,7 @@ class Db { else if (entityName === 'collection') return 'collections' else if (entityName === 'author') return 'authors' else if (entityName === 'series') return 'series' + else if (entityName === 'feed') return 'feeds' return null } @@ -83,6 +88,7 @@ class Db { this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 }) this.authorsDb = new njodb.Database(this.AuthorsPath) this.seriesDb = new njodb.Database(this.SeriesPath, { datastores: 2 }) + this.feedsDb = new njodb.Database(this.FeedsPath, { datastores: 2 }) return this.init() } @@ -116,21 +122,6 @@ class Db { async init() { await this.load() - // Insert Defaults - // var rootUser = this.users.find(u => u.type === 'root') - // if (!rootUser) { - // var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET) - // Logger.debug('Generated default token', token) - // Logger.info('[Db] Root user created') - // await this.insertEntity('user', this.getDefaultUser(token)) - // } else { - // Logger.info(`[Db] Root user exists, pw: ${rootUser.hasPw}`) - // } - - // if (!this.libraries.length) { - // await this.insertEntity('library', this.getDefaultLibrary()) - // } - if (!this.serverSettings) { this.serverSettings = new ServerSettings() await this.insertEntity('settings', this.serverSettings) @@ -278,6 +269,14 @@ class Db { return this.updateEntity('settings', this.serverSettings) } + getAllEntities(entityName) { + var entityDb = this.getEntityDb(entityName) + return entityDb.select(() => true).then((results) => results.data).catch((error) => { + Logger.error(`[DB] Failed to get all ${entityName}`, error) + return null + }) + } + insertEntities(entityName, entities) { var entityDb = this.getEntityDb(entityName) return entityDb.insert(entities).then((results) => { diff --git a/server/Server.js b/server/Server.js index 579d6a79..1c437e25 100644 --- a/server/Server.js +++ b/server/Server.js @@ -142,6 +142,7 @@ class Server { await this.backupManager.init() await this.logManager.init() + await this.rssFeedManager.init() this.podcastManager.init() if (this.db.serverSettings.scannerDisableWatcher) { @@ -194,14 +195,14 @@ class Server { // RSS Feed temp route app.get('/feed/:id', (req, res) => { - Logger.info(`[Server] requesting rss feed ${req.params.id}`) + Logger.info(`[Server] Requesting rss feed ${req.params.id}`) this.rssFeedManager.getFeed(req, res) }) app.get('/feed/:id/cover', (req, res) => { this.rssFeedManager.getFeedCover(req, res) }) - app.get('/feed/:id/item/*', (req, res) => { - Logger.info(`[Server] requesting rss feed ${req.params.id}`) + app.get('/feed/:id/item/:episodeId/*', (req, res) => { + Logger.debug(`[Server] Requesting rss feed episode ${req.params.id}/${req.params.episodeId}`) this.rssFeedManager.getFeedItem(req, res) }) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 416462b3..a40fba09 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -378,7 +378,7 @@ class LibraryItemController { return res.sendStatus(500) } - const feedData = this.rssFeedManager.openFeedForItem(req.user, req.libraryItem, req.body) + const feedData = await this.rssFeedManager.openFeedForItem(req.user, req.libraryItem, req.body) if (feedData.error) { return res.json({ success: false, @@ -398,7 +398,7 @@ class LibraryItemController { return res.sendStatus(500) } - this.rssFeedManager.closeFeedForItem(req.params.id) + await this.rssFeedManager.closeFeedForItem(req.params.id) res.sendStatus(200) } diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 7d46381e..e6ed676b 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -2,6 +2,7 @@ const Path = require('path') const fs = require('fs-extra') const date = require('date-and-time') const { Podcast } = require('podcast') +const Feed = require('../objects/Feed') const Logger = require('../Logger') // Not functional at the moment @@ -12,137 +13,73 @@ class RssFeedManager { this.feeds = {} } + async init() { + var feedObjects = await this.db.getAllEntities('feed') + if (feedObjects && feedObjects.length) { + feedObjects.forEach((feedObj) => { + var feed = new Feed(feedObj) + this.feeds[feed.id] = feed + Logger.info(`[RssFeedManager] Opened rss feed ${feed.feedUrl}`) + }) + } + } + findFeedForItem(libraryItemId) { - return Object.values(this.feeds).find(feed => feed.libraryItemId === libraryItemId) + return Object.values(this.feeds).find(feed => feed.entityId === libraryItemId) } getFeed(req, res) { - var feedData = this.feeds[req.params.id] - if (!feedData) { + var feed = this.feeds[req.params.id] + if (!feed) { Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`) res.sendStatus(404) return } - var xml = feedData.feed.buildXml() + // var xml = feedData.feed.buildXml() + var xml = feed.buildXml() res.set('Content-Type', 'text/xml') res.send(xml) } getFeedItem(req, res) { - var feedData = this.feeds[req.params.id] - if (!feedData) { + var feed = this.feeds[req.params.id] + if (!feed) { Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`) res.sendStatus(404) return } - var remainingPath = req.params['0'] - var fullPath = Path.join(feedData.libraryItemPath, remainingPath) - res.sendFile(fullPath) + var 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) + // var remainingPath = req.params['0'] + // var fullPath = Path.join(feedData.libraryItemPath, remainingPath) + // res.sendFile(fullPath) } getFeedCover(req, res) { - var feedData = this.feeds[req.params.id] - if (!feedData) { + var feed = this.feeds[req.params.id] + if (!feed) { Logger.error(`[RssFeedManager] Feed not found ${req.params.id}`) res.sendStatus(404) return } - if (!feedData.mediaCoverPath) { + if (!feed.coverPath) { res.sendStatus(404) return } - const extname = Path.extname(feedData.mediaCoverPath).toLowerCase().slice(1) + const extname = Path.extname(feedData.coverPath).toLowerCase().slice(1) res.type(`image/${extname}`) - var readStream = fs.createReadStream(feedData.mediaCoverPath) + var readStream = fs.createReadStream(feedData.coverPath) readStream.pipe(res) } - openFeed(userId, slug, libraryItem, serverAddress) { - const media = libraryItem.media - const mediaMetadata = media.metadata - const isPodcast = libraryItem.mediaType === 'podcast' - - const feedUrl = `${serverAddress}/feed/${slug}` - const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName - - const feed = new Podcast({ - title: mediaMetadata.title, - description: mediaMetadata.description, - feedUrl, - siteUrl: `${serverAddress}/items/${libraryItem.id}`, - imageUrl: media.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png`, - author: author || 'advplyr', - language: 'en' - }) - - if (isPodcast) { // PODCAST EPISODES - media.episodes.forEach((episode) => { - var contentUrl = episode.audioTrack.contentUrl.replace(/\\/g, '/') - contentUrl = contentUrl.replace(`/s/item/${libraryItem.id}`, `/feed/${slug}/item`) - - feed.addItem({ - title: episode.title, - description: episode.description || '', - enclosure: { - url: `${serverAddress}${contentUrl}`, - type: episode.audioTrack.mimeType, - size: episode.size - }, - date: episode.pubDate || '', - url: `${serverAddress}${contentUrl}`, - author: author || 'advplyr' - }) - }) - } else { // AUDIOBOOK EPISODES - - // Example: Fri, 04 Feb 2015 00:00:00 GMT - const audiobookPubDate = date.format(new Date(libraryItem.addedAt), 'ddd, DD MMM YYYY HH:mm:ss [GMT]') - - media.tracks.forEach((audioTrack) => { - var contentUrl = audioTrack.contentUrl.replace(/\\/g, '/') - contentUrl = contentUrl.replace(`/s/item/${libraryItem.id}`, `/feed/${slug}/item`) - - var title = audioTrack.title - if (media.chapters.length) { - // If audio track start and chapter start are within 1 seconds of eachother then use the chapter title - var matchingChapter = media.chapters.find(ch => Math.abs(ch.start - audioTrack.startOffset) < 1) - if (matchingChapter && matchingChapter.title) title = matchingChapter.title - } - - feed.addItem({ - title, - description: '', - enclosure: { - url: `${serverAddress}${contentUrl}`, - type: audioTrack.mimeType, - size: audioTrack.metadata.size - }, - date: audiobookPubDate, - url: `${serverAddress}${contentUrl}`, - author: author || 'advplyr' - }) - }) - } - - - const feedData = { - id: slug, - slug, - userId, - libraryItemId: libraryItem.id, - libraryItemPath: libraryItem.path, - mediaCoverPath: media.coverPath, - serverAddress: serverAddress, - feedUrl, - feed - } - this.feeds[slug] = feedData - return feedData - } - - openFeedForItem(user, libraryItem, options) { + async openFeedForItem(user, libraryItem, options) { const serverAddress = options.serverAddress const slug = options.slug @@ -153,24 +90,29 @@ class RssFeedManager { } } - const feedData = this.openFeed(user.id, slug, libraryItem, serverAddress) - Logger.debug(`[RssFeedManager] Opened RSS feed ${feedData.feedUrl}`) - this.emitter('rss_feed_open', { libraryItemId: libraryItem.id, feedUrl: feedData.feedUrl }) - return feedData + const feed = new Feed() + feed.setFromItem(user.id, slug, libraryItem, serverAddress) + this.feeds[feed.id] = feed + + Logger.debug(`[RssFeedManager] Opened RSS feed ${feed.feedUrl}`) + await this.db.insertEntity('feed', feed) + this.emitter('rss_feed_open', { entityType: feed.entityType, entityId: feed.entityId, feedUrl: feed.feedUrl }) + return feed } closeFeedForItem(libraryItemId) { var feed = this.findFeedForItem(libraryItemId) if (!feed) return - this.closeRssFeed(feed.id) + return this.closeRssFeed(feed.id) } - closeRssFeed(id) { + async closeRssFeed(id) { if (!this.feeds[id]) return - var feedData = this.feeds[id] - this.emitter('rss_feed_closed', { libraryItemId: feedData.libraryItemId, feedUrl: feedData.feedUrl }) + var feed = this.feeds[id] + await this.db.removeEntity('feed', id) + this.emitter('rss_feed_closed', { entityType: feed.entityType, entityId: feed.entityId, feedUrl: feed.feedUrl }) delete this.feeds[id] - Logger.info(`[RssFeedManager] Closed RSS feed "${feedData.feedUrl}"`) + Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedUrl}"`) } } module.exports = RssFeedManager \ No newline at end of file diff --git a/server/objects/Feed.js b/server/objects/Feed.js new file mode 100644 index 00000000..49dc0768 --- /dev/null +++ b/server/objects/Feed.js @@ -0,0 +1,118 @@ +const FeedMeta = require('./FeedMeta') +const FeedEpisode = require('./FeedEpisode') +const { Podcast } = require('podcast') + +class Feed { + constructor(feed) { + this.id = null + this.slug = null + this.userId = null + this.entityType = null + this.entityId = null + + this.coverPath = null + this.serverAddress = null + this.feedUrl = null + + this.meta = null + this.episodes = null + + this.createdAt = null + this.updatedAt = null + + if (feed) { + this.construct(feed) + } + } + + construct(feed) { + this.id = feed.id + this.slug = feed.slug + this.userId = feed.userId + this.entityType = feed.entityType + this.entityId = feed.entityId + this.coverPath = feed.coverPath + this.serverAddress = feed.serverAddress + this.feedUrl = feed.feedUrl + this.meta = new FeedMeta(feed.meta) + this.episodes = feed.episodes.map(ep => new FeedEpisode(ep)) + this.createdAt = feed.createdAt + this.updatedAt = feed.updatedAt + } + + toJSON() { + return { + id: this.id, + slug: this.slug, + userId: this.userId, + entityType: this.entityType, + entityId: this.entityId, + coverPath: this.coverPath, + serverAddress: this.serverAddress, + feedUrl: this.feedUrl, + meta: this.meta.toJSON(), + episodes: this.episodes.map(ep => ep.toJSON()), + createdAt: this.createdAt, + updatedAt: this.updatedAt + } + } + + getEpisodePath(id) { + var episode = this.episodes.find(ep => ep.id === id) + if (!episode) return null + return episode.fullPath + } + + setFromItem(userId, slug, libraryItem, serverAddress) { + const media = libraryItem.media + const mediaMetadata = media.metadata + const isPodcast = libraryItem.mediaType === 'podcast' + + const feedUrl = `${serverAddress}/feed/${slug}` + const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName + + this.id = slug + this.slug = slug + this.userId = userId + this.entityType = 'item' + this.entityId = libraryItem.id + this.coverPath = media.coverPath || null + this.serverAddress = serverAddress + this.feedUrl = feedUrl + + this.meta = new FeedMeta() + this.meta.title = mediaMetadata.title + this.meta.description = mediaMetadata.description + this.meta.author = author + this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png` + this.meta.feedUrl = feedUrl + this.meta.link = `${serverAddress}/items/${libraryItem.id}` + + this.episodes = [] + if (isPodcast) { // PODCAST EPISODES + media.episodes.forEach((episode) => { + var feedEpisode = new FeedEpisode() + feedEpisode.setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, this.meta) + this.episodes.push(feedEpisode) + }) + } else { // AUDIOBOOK EPISODES + media.tracks.forEach((audioTrack) => { + var feedEpisode = new FeedEpisode() + feedEpisode.setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, this.meta) + this.episodes.push(feedEpisode) + }) + } + + this.createdAt = Date.now() + this.updatedAt = Date.now() + } + + buildXml() { + const pod = new Podcast(this.meta.getPodcastMeta()) + this.episodes.forEach((ep) => { + pod.addItem(ep.getPodcastEpisode()) + }) + return pod.buildXml() + } +} +module.exports = Feed \ No newline at end of file diff --git a/server/objects/FeedEpisode.js b/server/objects/FeedEpisode.js new file mode 100644 index 00000000..86b66623 --- /dev/null +++ b/server/objects/FeedEpisode.js @@ -0,0 +1,130 @@ +const Path = require('path') +const date = require('date-and-time') + +class FeedEpisode { + constructor(episode) { + this.id = null + + this.title = null + this.description = null + this.enclosure = null + this.pubDate = null + this.link = null + this.author = null + this.explicit = null + this.duration = null + + this.libraryItemId = null + this.episodeId = null + this.trackIndex = null + this.fullPath = null + + if (episode) { + this.construct(episode) + } + } + + construct(episode) { + this.id = episode.id + this.title = episode.title + this.description = episode.description + this.enclosure = episode.enclosure ? { ...episode.enclosure } : null + this.pubDate = episode.pubDate + this.link = episode.link + this.author = episode.author + this.explicit = episode.explicit + this.duration = episode.duration + this.libraryItemId = episode.libraryItemId + this.episodeId = episode.episodeId || null + this.trackIndex = episode.trackIndex || 0 + this.fullPath = episode.fullPath + } + + toJSON() { + return { + id: this.id, + title: this.title, + description: this.description, + enclosure: this.enclosure ? { ...this.enclosure } : null, + pubDate: this.pubDate, + link: this.link, + author: this.author, + explicit: this.explicit, + duration: this.duration, + libraryItemId: this.libraryItemId, + episodeId: this.episodeId, + trackIndex: this.trackIndex, + fullPath: this.fullPath + } + } + + setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, meta) { + const contentUrl = `/feed/${slug}/item/${episode.id}/${episode.audioFile.metadata.filename}` + const media = libraryItem.media + const mediaMetadata = media.metadata + + this.id = episode.id + this.title = episode.title + this.description = episode.description || '' + this.enclosure = { + url: `${serverAddress}${contentUrl}`, + type: episode.audioTrack.mimeType, + size: episode.size + } + this.pubDate = episode.pubDate + this.link = meta.link + this.author = meta.author + this.explicit = mediaMetadata.explicit + this.duration = episode.duration + this.libraryItemId = libraryItem.id + this.episodeId = episode.id + this.trackIndex = 0 + this.fullPath = episode.audioFile.metadata.path + } + + setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta) { + // Example: Fri, 04 Feb 2015 00:00:00 GMT + const audiobookPubDate = date.format(new Date(libraryItem.addedAt), 'ddd, DD MMM YYYY HH:mm:ss [GMT]') + + const contentUrl = `/feed/${slug}/item/${audioTrack.index}/${audioTrack.metadata.filename}` + const media = libraryItem.media + const mediaMetadata = media.metadata + + var title = audioTrack.title + if (libraryItem.media.chapters.length) { + // If audio track start and chapter start are within 1 seconds of eachother then use the chapter title + var matchingChapter = libraryItem.media.chapters.find(ch => Math.abs(ch.start - audioTrack.startOffset) < 1) + if (matchingChapter && matchingChapter.title) title = matchingChapter.title + } + + this.id = String(audioTrack.index) + this.title = title + this.description = mediaMetadata.description || '' + this.enclosure = { + url: `${serverAddress}${contentUrl}`, + type: audioTrack.mimeType, + size: audioTrack.metadata.size + } + this.pubDate = audiobookPubDate + this.link = meta.link + this.author = meta.author + this.explicit = mediaMetadata.explicit + this.duration = audioTrack.duration + this.libraryItemId = libraryItem.id + this.episodeId = null + this.trackIndex = audioTrack.index + this.fullPath = audioTrack.metadata.path + } + + getPodcastEpisode() { + return { + title: this.title, + description: this.description || '', + enclosure: this.enclosure, + date: this.pubDate || '', + url: this.link, + author: this.author + } + } +} +module.exports = FeedEpisode \ No newline at end of file diff --git a/server/objects/FeedMeta.js b/server/objects/FeedMeta.js new file mode 100644 index 00000000..14dc2756 --- /dev/null +++ b/server/objects/FeedMeta.js @@ -0,0 +1,47 @@ +class FeedMeta { + constructor(meta) { + this.title = null + this.description = null + this.author = null + this.imageUrl = null + this.feedUrl = null + this.link = null + + if (meta) { + this.construct(meta) + } + } + + construct(meta) { + this.title = meta.title + this.description = meta.description + this.author = meta.author + this.imageUrl = meta.imageUrl + this.feedUrl = meta.feedUrl + this.link = meta.link + } + + toJSON() { + return { + title: this.title, + description: this.description, + author: this.author, + imageUrl: this.imageUrl, + feedUrl: this.feedUrl, + link: this.link + } + } + + getPodcastMeta() { + return { + title: this.title, + description: this.description, + feedUrl: this.feedUrl, + siteUrl: this.link, + imageUrl: this.imageUrl, + author: this.author || 'advplyr', + language: 'en' + } + } +} +module.exports = FeedMeta \ No newline at end of file