From 9bd1f9e3d570401a6f5146bdd2384ade7a0c26e8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 14 Dec 2024 16:55:56 -0600 Subject: [PATCH] Refactor Feed model to create new feed for library item --- server/controllers/RSSFeedController.js | 29 ++-- server/managers/RssFeedManager.js | 43 ++++-- server/models/Book.js | 29 ++++ server/models/Feed.js | 144 +++++++++++++++++- server/models/FeedEpisode.js | 188 +++++++++++++++++++++--- server/models/LibraryItem.js | 21 +++ server/objects/Feed.js | 58 -------- 7 files changed, 400 insertions(+), 112 deletions(-) diff --git a/server/controllers/RSSFeedController.js b/server/controllers/RSSFeedController.js index 2e07c10e..b16820aa 100644 --- a/server/controllers/RSSFeedController.js +++ b/server/controllers/RSSFeedController.js @@ -38,38 +38,43 @@ class RSSFeedController { * @param {Response} res */ async openRSSFeedForItem(req, res) { - const options = req.body || {} + const reqBody = req.body || {} - const item = await Database.libraryItemModel.getOldById(req.params.itemId) - if (!item) return res.sendStatus(404) + const itemExpanded = await Database.libraryItemModel.getExpandedById(req.params.itemId) + if (!itemExpanded) return res.sendStatus(404) // Check user can access this library item - if (!req.user.checkCanAccessLibraryItem(item)) { - Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${item.media.metadata.title}" that they don\'t have access to`) + if (!req.user.checkCanAccessLibraryItem(itemExpanded)) { + Logger.error(`[RSSFeedController] User "${req.user.username}" attempted to open an RSS feed for item "${itemExpanded.media.title}" that they don\'t have access to`) return res.sendStatus(403) } // Check request body options exist - if (!options.serverAddress || !options.slug) { + if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') { Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) return res.status(400).send('Invalid request body') } // Check item has audio tracks - if (!item.media.numTracks) { - Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${item.media.metadata.title}" because it has no audio tracks`) + if (!itemExpanded.hasAudioTracks()) { + Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${itemExpanded.media.title}" because it has no audio tracks`) return res.status(400).send('Item has no audio tracks') } // Check that this slug is not being used for another feed (slug will also be the Feed id) - if (await this.rssFeedManager.findFeedBySlug(options.slug)) { - Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) + if (await this.rssFeedManager.findFeedBySlug(reqBody.slug)) { + Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`) return res.status(400).send('Slug already in use') } - const feed = await this.rssFeedManager.openFeedForItem(req.user.id, item, req.body) + const feed = await this.rssFeedManager.openFeedForItem(req.user.id, itemExpanded, reqBody) + if (!feed) { + Logger.error(`[RSSFeedController] Failed to open RSS feed for item "${itemExpanded.media.title}"`) + return res.status(500).send('Failed to open RSS feed') + } + res.json({ - feed: feed.toJSONMinified() + feed: feed.toOldJSONMinified() }) } diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 583f0bb6..3feb87d0 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -223,25 +223,42 @@ class RssFeedManager { /** * - * @param {string} userId - * @param {*} libraryItem * @param {*} options - * @returns + * @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 preventIndexing = options.metadataDetails?.preventIndexing ?? true - const ownerName = options.metadataDetails?.ownerName - const ownerEmail = options.metadataDetails?.ownerEmail + const feedOptions = this.getFeedOptionsFromReqOptions(options) - const feed = new Feed() - feed.setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail) - - Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) - await Database.createFeed(feed) - SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) - return feed + 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 } /** diff --git a/server/models/Book.js b/server/models/Book.js index a8ccf73d..71a55033 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -106,6 +106,9 @@ class Book extends Model { this.updatedAt /** @type {Date} */ this.createdAt + + /** @type {import('./Author')[]} - optional if expanded */ + this.authors } static getOldBook(libraryItemExpanded) { @@ -320,6 +323,32 @@ class Book extends Model { } ) } + + /** + * Comma separated array of author names + * Requires authors to be loaded + * + * @returns {string} + */ + get authorName() { + if (this.authors === undefined) { + Logger.error(`[Book] authorName: Cannot get authorName because authors are not loaded`) + return '' + } + return this.authors.map((au) => au.name).join(', ') + } + get includedAudioFiles() { + return this.audioFiles.filter((af) => !af.exclude) + } + get trackList() { + let startOffset = 0 + return this.includedAudioFiles.map((af) => { + const track = structuredClone(af) + track.startOffset = startOffset + startOffset += track.duration + return track + }) + } } module.exports = Book diff --git a/server/models/Feed.js b/server/models/Feed.js index 4f51e66d..ff25a612 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -1,7 +1,22 @@ +const Path = require('path') const { DataTypes, Model } = require('sequelize') const oldFeed = require('../objects/Feed') const areEquivalent = require('../utils/areEquivalent') +/** + * @typedef FeedOptions + * @property {boolean} preventIndexing + * @property {string} ownerName + * @property {string} ownerEmail + */ + +/** + * @typedef FeedExpandedProperties + * @property {import('./FeedEpisode')} feedEpisodes + * + * @typedef {Feed & FeedExpandedProperties} FeedExpanded + */ + class Feed extends Model { constructor(values, options) { super(values, options) @@ -50,6 +65,9 @@ class Feed extends Model { this.createdAt /** @type {Date} */ this.updatedAt + + /** @type {import('./FeedEpisode')[]} - only set if expanded */ + this.feedEpisodes } static async getOldFeeds() { @@ -67,7 +85,15 @@ class Feed extends Model { * @returns {oldFeed} */ static getOldFeed(feedExpanded) { - const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) + const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) || [] + + // Sort episodes by pubDate. Newest to oldest for episodic, oldest to newest for serial + if (feedExpanded.podcastType === 'episodic') { + episodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate)) + } else { + episodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate)) + } + return new oldFeed({ id: feedExpanded.id, slug: feedExpanded.slug, @@ -92,7 +118,7 @@ class Feed extends Model { }, serverAddress: feedExpanded.serverAddress, feedUrl: feedExpanded.feedURL, - episodes: episodes || [], + episodes, createdAt: feedExpanded.createdAt.valueOf(), updatedAt: feedExpanded.updatedAt.valueOf() }) @@ -250,10 +276,62 @@ class Feed extends Model { } } - getEntity(options) { - if (!this.entityType) return Promise.resolve(null) - const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}` - return this[mixinMethodName](options) + /** + * + * @param {string} userId + * @param {string} slug + * @param {import('./LibraryItem').LibraryItemExpanded} libraryItem + * @param {string} serverAddress + * @param {FeedOptions} feedOptions + * + * @returns {Promise} + */ + static async createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) { + const media = libraryItem.media + + const feedObj = { + slug, + entityType: 'libraryItem', + entityId: libraryItem.id, + entityUpdatedAt: libraryItem.updatedAt, + serverAddress, + feedURL: `/feed/${slug}`, + imageURL: media.coverPath ? `/feed/${slug}/cover${Path.extname(media.coverPath)}` : `/Logo.png`, + siteURL: `/item/${libraryItem.id}`, + title: media.title, + description: media.description, + author: libraryItem.mediaType === 'podcast' ? media.author : media.authorName, + podcastType: libraryItem.mediaType === 'podcast' ? media.podcastType : 'serial', + language: media.language, + preventIndexing: feedOptions.preventIndexing, + ownerName: feedOptions.ownerName, + ownerEmail: feedOptions.ownerEmail, + explicit: media.explicit, + coverPath: media.coverPath, + userId + } + + /** @type {typeof import('./FeedEpisode')} */ + const feedEpisodeModel = this.sequelize.models.feedEpisode + + const transaction = await this.sequelize.transaction() + try { + const feed = await this.create(feedObj, { transaction }) + + if (libraryItem.mediaType === 'podcast') { + feed.feedEpisodes = await feedEpisodeModel.createFromPodcastEpisodes(libraryItem, feed, slug, transaction) + } else { + feed.feedEpisodes = await feedEpisodeModel.createFromAudiobookTracks(libraryItem, feed, slug, transaction) + } + + await transaction.commit() + + return feed + } catch (error) { + Logger.error(`[Feed] Error creating feed for library item ${libraryItem.id}`, error) + await transaction.rollback() + return null + } } /** @@ -369,6 +447,60 @@ class Feed extends Model { } }) } + + getEntity(options) { + if (!this.entityType) return Promise.resolve(null) + const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}` + return this[mixinMethodName](options) + } + + toOldJSON() { + const episodes = this.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) + return { + id: this.id, + slug: this.slug, + userId: this.userId, + entityType: this.entityType, + entityId: this.entityId, + entityUpdatedAt: this.entityUpdatedAt?.valueOf() || null, + coverPath: this.coverPath || null, + meta: { + title: this.title, + description: this.description, + author: this.author, + imageUrl: this.imageURL, + feedUrl: this.feedURL, + link: this.siteURL, + explicit: this.explicit, + type: this.podcastType, + language: this.language, + preventIndexing: this.preventIndexing, + ownerName: this.ownerName, + ownerEmail: this.ownerEmail + }, + serverAddress: this.serverAddress, + feedUrl: this.feedURL, + episodes: episodes || [], + createdAt: this.createdAt.valueOf(), + updatedAt: this.updatedAt.valueOf() + } + } + + toOldJSONMinified() { + return { + id: this.id, + entityType: this.entityType, + entityId: this.entityId, + feedUrl: this.feedURL, + meta: { + title: this.title, + description: this.description, + preventIndexing: this.preventIndexing, + ownerName: this.ownerName, + ownerEmail: this.ownerEmail + } + } + } } module.exports = Feed diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js index 442cc165..0a90f97d 100644 --- a/server/models/FeedEpisode.js +++ b/server/models/FeedEpisode.js @@ -1,4 +1,8 @@ +const Path = require('path') const { DataTypes, Model } = require('sequelize') +const uuidv4 = require('uuid').v4 +const Logger = require('../Logger') +const date = require('../libs/dateAndTime') class FeedEpisode extends Model { constructor(values, options) { @@ -40,29 +44,6 @@ class FeedEpisode extends Model { this.updatedAt } - getOldEpisode() { - const enclosure = { - url: this.enclosureURL, - size: this.enclosureSize, - type: this.enclosureType - } - return { - id: this.id, - title: this.title, - description: this.description, - enclosure, - pubDate: this.pubDate, - link: this.siteURL, - author: this.author, - explicit: this.explicit, - duration: this.duration, - season: this.season, - episode: this.episode, - episodeType: this.episodeType, - fullPath: this.filePath - } - } - /** * Create feed episode from old model * @@ -96,6 +77,144 @@ class FeedEpisode extends Model { } } + /** + * + * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded + * @param {import('./Feed')} feed + * @param {string} slug + * @param {import('./PodcastEpisode')} episode + */ + static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) { + return { + title: episode.title, + author: feed.author, + description: episode.description, + siteURL: feed.siteURL, + enclosureURL: `/feed/${slug}/item/${episode.id}/media${Path.extname(episode.audioFile.metadata.filename)}`, + enclosureType: episode.audioFile.mimeType, + enclosureSize: episode.audioFile.metadata.size, + pubDate: episode.pubDate, + season: episode.season, + episode: episode.episode, + episodeType: episode.episodeType, + duration: episode.audioFile.duration, + filePath: episode.audioFile.metadata.path, + explicit: libraryItemExpanded.media.explicit, + feedId: feed.id + } + } + + /** + * + * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded + * @param {import('./Feed')} feed + * @param {string} slug + * @param {import('sequelize').Transaction} transaction + * @returns {Promise} + */ + static async createFromPodcastEpisodes(libraryItemExpanded, feed, slug, transaction) { + const feedEpisodeObjs = [] + + // Sort podcastEpisodes by pubDate. episodic is newest to oldest. serial is oldest to newest. + if (feed.podcastType === 'episodic') { + libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate)) + } else { + libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate)) + } + + for (const episode of libraryItemExpanded.media.podcastEpisodes) { + feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode)) + } + Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`) + return this.bulkCreate(feedEpisodeObjs, { transaction }) + } + + /** + * If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names + * + * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded + * @returns {boolean} + */ + static checkUseChapterTitlesForEpisodes(libraryItemExpanded) { + const tracks = libraryItemExpanded.media.trackList || [] + const chapters = libraryItemExpanded.media.chapters || [] + if (tracks.length !== chapters.length) return false + for (let i = 0; i < tracks.length; i++) { + if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) { + return false + } + } + return true + } + + /** + * + * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded + * @param {import('./Feed')} feed + * @param {string} slug + * @param {import('./Book').AudioFileObject} audioTrack + * @param {boolean} useChapterTitles + * @param {string} [pubDateOverride] + */ + static getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded, feed, slug, audioTrack, useChapterTitles, pubDateOverride = null) { + // Example: Fri, 04 Feb 2015 00:00:00 GMT + let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order + let episodeId = uuidv4() + + // e.g. Track 1 will have a pub date before Track 2 + const audiobookPubDate = pubDateOverride || date.format(new Date(libraryItemExpanded.createdAt.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]') + + const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}` + const media = libraryItemExpanded.media + + let title = audioTrack.title + if (media.trackList.length == 1) { + // If audiobook is a single file, use book title instead of chapter/file title + title = media.title + } else { + if (useChapterTitles) { + // If audio track start and chapter start are within 1 seconds of eachother then use the chapter title + const matchingChapter = media.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1) + if (matchingChapter?.title) title = matchingChapter.title + } + } + + return { + id: episodeId, + title, + author: feed.author, + description: media.description || '', + siteURL: feed.siteURL, + enclosureURL: contentUrl, + enclosureType: audioTrack.mimeType, + enclosureSize: audioTrack.metadata.size, + pubDate: audiobookPubDate, + duration: audioTrack.duration, + filePath: audioTrack.metadata.path, + explicit: media.explicit, + feedId: feed.id + } + } + + /** + * + * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded + * @param {import('./Feed')} feed + * @param {string} slug + * @param {import('sequelize').Transaction} transaction + * @returns {Promise} + */ + static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) { + const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded) + + const feedEpisodeObjs = [] + for (const track of libraryItemExpanded.media.trackList) { + feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded, feed, slug, track, useChapterTitles)) + } + Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`) + return this.bulkCreate(feedEpisodeObjs, { transaction }) + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize @@ -136,6 +255,29 @@ class FeedEpisode extends Model { }) FeedEpisode.belongsTo(feed) } + + getOldEpisode() { + const enclosure = { + url: this.enclosureURL, + size: this.enclosureSize, + type: this.enclosureType + } + return { + id: this.id, + title: this.title, + description: this.description, + enclosure, + pubDate: this.pubDate, + link: this.siteURL, + author: this.author, + explicit: this.explicit, + duration: this.duration, + season: this.season, + episode: this.episode, + episodeType: this.episodeType, + fullPath: this.filePath + } + } } module.exports = FeedEpisode diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 10395c49..0c8df302 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -73,6 +73,9 @@ class LibraryItem extends Model { this.createdAt /** @type {Date} */ this.updatedAt + + /** @type {Book.BookExpanded|Podcast.PodcastExpanded} - only set when expanded */ + this.media } /** @@ -1124,6 +1127,24 @@ class LibraryItem extends Model { } }) } + + /** + * Check if book or podcast library item has audio tracks + * Requires expanded library item + * + * @returns {boolean} + */ + hasAudioTracks() { + if (!this.media) { + Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`) + return false + } + if (this.mediaType === 'book') { + return this.media.audioFiles?.length > 0 + } else { + return this.media.podcastEpisodes?.length > 0 + } + } } module.exports = LibraryItem diff --git a/server/objects/Feed.js b/server/objects/Feed.js index ac50b899..dd086bb0 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -101,64 +101,6 @@ class Feed { return true } - setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { - const media = libraryItem.media - const mediaMetadata = media.metadata - const isPodcast = libraryItem.mediaType === 'podcast' - - const feedUrl = `/feed/${slug}` - const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName - - this.id = uuidv4() - this.slug = slug - this.userId = userId - this.entityType = 'libraryItem' - this.entityId = libraryItem.id - this.entityUpdatedAt = libraryItem.updatedAt - this.coverPath = media.coverPath || null - this.serverAddress = serverAddress - this.feedUrl = feedUrl - - const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null - - this.meta = new FeedMeta() - this.meta.title = mediaMetadata.title - this.meta.description = mediaMetadata.description - this.meta.author = author - this.meta.imageUrl = media.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png` - this.meta.feedUrl = feedUrl - this.meta.link = `/item/${libraryItem.id}` - this.meta.explicit = !!mediaMetadata.explicit - this.meta.type = mediaMetadata.type - this.meta.language = mediaMetadata.language - this.meta.preventIndexing = preventIndexing - this.meta.ownerName = ownerName - this.meta.ownerEmail = ownerEmail - - this.episodes = [] - if (isPodcast) { - // PODCAST EPISODES - media.episodes.forEach((episode) => { - if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt - - const feedEpisode = new FeedEpisode() - feedEpisode.setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, this.meta) - this.episodes.push(feedEpisode) - }) - } else { - // AUDIOBOOK EPISODES - const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItem) - media.tracks.forEach((audioTrack) => { - const feedEpisode = new FeedEpisode() - feedEpisode.setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, this.meta, useChapterTitles) - this.episodes.push(feedEpisode) - }) - } - - this.createdAt = Date.now() - this.updatedAt = Date.now() - } - updateFromItem(libraryItem) { const media = libraryItem.media const mediaMetadata = media.metadata