From d576625cb76ed35cc0bfe7dc0910b88ae262a0d4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 15 Dec 2024 10:53:31 -0600 Subject: [PATCH] Refactor Feed model to create new feed for collection --- server/controllers/RSSFeedController.js | 22 ++++---- server/managers/RssFeedManager.js | 22 ++++---- server/models/Book.js | 6 +++ server/models/Collection.js | 34 +++++++++++++ server/models/Feed.js | 68 ++++++++++++++++++++++++- server/models/FeedEpisode.js | 57 +++++++++++++++------ server/objects/Feed.js | 55 -------------------- 7 files changed, 171 insertions(+), 93 deletions(-) diff --git a/server/controllers/RSSFeedController.js b/server/controllers/RSSFeedController.js index b16820aa..65c823a4 100644 --- a/server/controllers/RSSFeedController.js +++ b/server/controllers/RSSFeedController.js @@ -87,35 +87,39 @@ class RSSFeedController { * @param {Response} res */ async openRSSFeedForCollection(req, res) { - const options = req.body || {} + const reqBody = req.body || {} const collection = await Database.collectionModel.findByPk(req.params.collectionId) if (!collection) return res.sendStatus(404) // 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 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 collectionExpanded = await collection.getOldJsonExpanded() - const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length) + collection.books = await collection.getBooksExpandedWithLibraryItem() // Check collection has audio tracks - if (!collectionItemsWithTracks.length) { + if (!collection.books.some((book) => book.includedAudioFiles.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.id, collectionExpanded, req.body) + const feed = await this.rssFeedManager.openFeedForCollection(req.user.id, collection, reqBody) + if (!feed) { + Logger.error(`[RSSFeedController] Failed to open RSS feed for collection "${collection.name}"`) + 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 3feb87d0..032aa4f2 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -264,24 +264,22 @@ class RssFeedManager { /** * * @param {string} userId - * @param {*} collectionExpanded + * @param {import('../models/Collection')} collectionExpanded * @param {*} options - * @returns + * @returns {Promise} */ async openFeedForCollection(userId, collectionExpanded, 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.setFromCollection(userId, slug, collectionExpanded, 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 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 } /** diff --git a/server/models/Book.js b/server/models/Book.js index 71a55033..f7341db9 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -29,6 +29,12 @@ const Logger = require('../Logger') * @property {SeriesExpanded[]} series * * @typedef {Book & BookExpandedProperties} BookExpanded + * + * Collections use BookExpandedWithLibraryItem + * @typedef BookExpandedWithLibraryItemProperties + * @property {import('./LibraryItem')} libraryItem + * + * @typedef {BookExpanded & BookExpandedWithLibraryItemProperties} BookExpandedWithLibraryItem */ /** diff --git a/server/models/Collection.js b/server/models/Collection.js index a001dc5b..0fb622dd 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -1,6 +1,7 @@ const { DataTypes, Model, Sequelize } = require('sequelize') const oldCollection = require('../objects/Collection') +const Logger = require('../Logger') class Collection extends Model { constructor(values, options) { @@ -18,6 +19,11 @@ class Collection extends Model { this.updatedAt /** @type {Date} */ this.createdAt + + // Expanded properties + + /** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */ + this.books } /** @@ -219,6 +225,34 @@ class Collection extends Model { Collection.belongsTo(library) } + /** + * Get all books in collection expanded with library item + * + * @returns {Promise} + */ + getBooksExpandedWithLibraryItem() { + return this.getBooks({ + include: [ + { + model: this.sequelize.models.libraryItem + }, + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } + } + ], + order: [Sequelize.literal('`collectionBook.order` ASC')] + }) + } + /** * Get old collection toJSONExpanded, items filtered for user permissions * diff --git a/server/models/Feed.js b/server/models/Feed.js index ff25a612..f22c1ac3 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -279,8 +279,8 @@ class Feed extends Model { /** * * @param {string} userId - * @param {string} slug * @param {import('./LibraryItem').LibraryItemExpanded} libraryItem + * @param {string} slug * @param {string} serverAddress * @param {FeedOptions} feedOptions * @@ -334,6 +334,72 @@ class Feed extends Model { } } + /** + * + * @param {string} userId + * @param {import('./Collection')} collectionExpanded + * @param {string} slug + * @param {string} serverAddress + * @param {FeedOptions} feedOptions + * + * @returns {Promise} + */ + static async createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions) { + const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length) + const libraryItemMostRecentlyUpdatedAt = booksWithTracks.reduce((mostRecent, book) => { + return book.libraryItem.updatedAt > mostRecent.libraryItem.updatedAt ? book : mostRecent + }).libraryItem.updatedAt + + const firstBookWithCover = booksWithTracks.find((book) => book.coverPath) + + const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => { + const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name) + return authorNames.concat(bookAuthorsToAdd) + }, []) + let author = allBookAuthorNames.slice(0, 3).join(', ') + if (allBookAuthorNames.length > 3) { + author += ' & more' + } + + const feedObj = { + slug, + entityType: 'collection', + entityId: collectionExpanded.id, + entityUpdatedAt: libraryItemMostRecentlyUpdatedAt, + serverAddress, + feedURL: `/feed/${slug}`, + imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`, + siteURL: `/collection/${collectionExpanded.id}`, + title: collectionExpanded.name, + description: collectionExpanded.description || '', + author, + podcastType: 'serial', + preventIndexing: feedOptions.preventIndexing, + ownerName: feedOptions.ownerName, + ownerEmail: feedOptions.ownerEmail, + explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit + coverPath: firstBookWithCover?.coverPath || null, + 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 }) + feed.feedEpisodes = await feedEpisodeModel.createFromCollectionBooks(collectionExpanded, feed, slug, transaction) + + await transaction.commit() + + return feed + } catch (error) { + Logger.error(`[Feed] Error creating feed for collection ${collectionExpanded.id}`, error) + await transaction.rollback() + return null + } + } + /** * Initialize model * diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js index 0a90f97d..c92ee9cc 100644 --- a/server/models/FeedEpisode.js +++ b/server/models/FeedEpisode.js @@ -132,12 +132,12 @@ class FeedEpisode extends Model { /** * If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names * - * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded + * @param {import('./Book')} book * @returns {boolean} */ - static checkUseChapterTitlesForEpisodes(libraryItemExpanded) { - const tracks = libraryItemExpanded.media.trackList || [] - const chapters = libraryItemExpanded.media.chapters || [] + static checkUseChapterTitlesForEpisodes(book) { + const tracks = book.trackList || [] + const chapters = book.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) { @@ -149,32 +149,31 @@ class FeedEpisode extends Model { /** * - * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded + * @param {import('./Book')} book + * @param {Date} pubDateStart * @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) { + static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles) { // 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 audiobookPubDate = date.format(new Date(pubDateStart.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 (book.trackList.length == 1) { // If audiobook is a single file, use book title instead of chapter/file title - title = media.title + title = book.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) + const matchingChapter = book.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1) if (matchingChapter?.title) title = matchingChapter.title } } @@ -183,7 +182,7 @@ class FeedEpisode extends Model { id: episodeId, title, author: feed.author, - description: media.description || '', + description: book.description || '', siteURL: feed.siteURL, enclosureURL: contentUrl, enclosureType: audioTrack.mimeType, @@ -191,7 +190,7 @@ class FeedEpisode extends Model { pubDate: audiobookPubDate, duration: audioTrack.duration, filePath: audioTrack.metadata.path, - explicit: media.explicit, + explicit: book.explicit, feedId: feed.id } } @@ -205,11 +204,37 @@ class FeedEpisode extends Model { * @returns {Promise} */ static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) { - const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded) + const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded.media) const feedEpisodeObjs = [] for (const track of libraryItemExpanded.media.trackList) { - feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded, feed, slug, track, useChapterTitles)) + feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles)) + } + Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`) + return this.bulkCreate(feedEpisodeObjs, { transaction }) + } + + /** + * + * @param {import('./Collection')} collectionExpanded + * @param {import('./Feed')} feed + * @param {string} slug + * @param {import('sequelize').Transaction} transaction + * @returns {Promise} + */ + static async createFromCollectionBooks(collectionExpanded, feed, slug, transaction) { + const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length) + + const earliestLibraryItemCreatedAt = collectionExpanded.books.reduce((earliest, book) => { + return book.libraryItem.createdAt < earliest.libraryItem.createdAt ? book : earliest + }).libraryItem.createdAt + + const feedEpisodeObjs = [] + for (const book of booksWithTracks) { + const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book) + for (const track of book.trackList) { + feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles)) + } } Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`) return this.bulkCreate(feedEpisodeObjs, { transaction }) diff --git a/server/objects/Feed.js b/server/objects/Feed.js index dd086bb0..378ef3ee 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -143,61 +143,6 @@ class Feed { this.updatedAt = Date.now() } - setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { - const feedUrl = `/feed/${slug}` - - const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length) - const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath) - - this.id = uuidv4() - this.slug = slug - this.userId = userId - this.entityType = 'collection' - this.entityId = collectionExpanded.id - this.entityUpdatedAt = collectionExpanded.lastUpdate // This will be set to the most recently updated library item - this.coverPath = firstItemWithCover?.media.coverPath || null - this.serverAddress = serverAddress - this.feedUrl = feedUrl - - const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null - - 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 ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png` - this.meta.feedUrl = feedUrl - this.meta.link = `/collection/${collectionExpanded.id}` - this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit - this.meta.preventIndexing = preventIndexing - this.meta.ownerName = ownerName - this.meta.ownerEmail = ownerEmail - - this.episodes = [] - - // Used for calculating pubdate - const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt) - - itemsWithTracks.forEach((item, index) => { - if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt - - const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item) - item.media.tracks.forEach((audioTrack) => { - const feedEpisode = new FeedEpisode() - - // Offset pubdate to ensure correct order - let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track - trackTimeOffset += index * 1000 // Offset item - const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]') - feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride) - 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)