Refactor Feed model to create new feed for series

This commit is contained in:
advplyr 2024-12-15 11:44:07 -06:00
parent d576625cb7
commit e50bd93958
6 changed files with 130 additions and 91 deletions

View File

@ -132,37 +132,39 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async openRSSFeedForSeries(req, res) { async openRSSFeedForSeries(req, res) {
const options = req.body || {} const reqBody = req.body || {}
const series = await Database.seriesModel.findByPk(req.params.seriesId) const series = await Database.seriesModel.findByPk(req.params.seriesId)
if (!series) return res.sendStatus(404) if (!series) return res.sendStatus(404)
// Check request body options exist // 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`) Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body') 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) // 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)) { if (await this.rssFeedManager.findFeedBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const seriesJson = series.toOldJSON() series.books = await series.getBooksExpandedWithLibraryItem()
// Get books in series that have audio tracks
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
// Check series has audio tracks // Check series has audio tracks
if (!seriesJson.books.length) { if (!series.books.some((book) => book.includedAudioFiles.length)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${seriesJson.name}" because it has no audio tracks`) Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${series.name}" because it has no audio tracks`)
return res.status(400).send('Series has no audio tracks') return res.status(400).send('Series has no audio tracks')
} }
const feed = await this.rssFeedManager.openFeedForSeries(req.user.id, seriesJson, req.body) const feed = await this.rssFeedManager.openFeedForSeries(req.user.id, series, req.body)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for series "${series.name}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({ res.json({
feed: feed.toJSONMinified() feed: feed.toOldJSONMinified()
}) })
} }

View File

@ -285,24 +285,22 @@ class RssFeedManager {
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {*} seriesExpanded * @param {import('../models/Series')} seriesExpanded
* @param {*} options * @param {*} options
* @returns * @returns {Promise<import('../models/Feed').FeedExpanded>}
*/ */
async openFeedForSeries(userId, seriesExpanded, options) { async openFeedForSeries(userId, seriesExpanded, options) {
const serverAddress = options.serverAddress const serverAddress = options.serverAddress
const slug = options.slug const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true const feedOptions = this.getFeedOptionsFromReqOptions(options)
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed() Logger.info(`[RssFeedManager] Creating RSS feed for series "${seriesExpanded.name}"`)
feed.setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail) const feedExpanded = await Database.feedModel.createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
await Database.createFeed(feed) SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) }
return feed return feedExpanded
} }
async handleCloseFeed(feed) { async handleCloseFeed(feed) {

View File

@ -388,7 +388,73 @@ class Feed extends Model {
const transaction = await this.sequelize.transaction() const transaction = await this.sequelize.transaction()
try { try {
const feed = await this.create(feedObj, { transaction }) const feed = await this.create(feedObj, { transaction })
feed.feedEpisodes = await feedEpisodeModel.createFromCollectionBooks(collectionExpanded, feed, slug, transaction) feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, 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
}
}
/**
*
* @param {string} userId
* @param {import('./Series')} seriesExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} feedOptions
*
* @returns {Promise<FeedExpanded>}
*/
static async createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions) {
const booksWithTracks = seriesExpanded.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: 'series',
entityId: seriesExpanded.id,
entityUpdatedAt: libraryItemMostRecentlyUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
siteURL: `/library/${booksWithTracks[0].libraryItem.libraryId}/series/${seriesExpanded.id}`,
title: seriesExpanded.name,
description: seriesExpanded.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.createFromBooks(booksWithTracks, feed, slug, transaction)
await transaction.commit() await transaction.commit()

View File

@ -216,21 +216,19 @@ class FeedEpisode extends Model {
/** /**
* *
* @param {import('./Collection')} collectionExpanded * @param {import('./Book')[]} books
* @param {import('./Feed')} feed * @param {import('./Feed')} feed
* @param {string} slug * @param {string} slug
* @param {import('sequelize').Transaction} transaction * @param {import('sequelize').Transaction} transaction
* @returns {Promise<FeedEpisode[]>} * @returns {Promise<FeedEpisode[]>}
*/ */
static async createFromCollectionBooks(collectionExpanded, feed, slug, transaction) { static async createFromBooks(books, feed, slug, transaction) {
const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length) const earliestLibraryItemCreatedAt = books.reduce((earliest, book) => {
const earliestLibraryItemCreatedAt = collectionExpanded.books.reduce((earliest, book) => {
return book.libraryItem.createdAt < earliest.libraryItem.createdAt ? book : earliest return book.libraryItem.createdAt < earliest.libraryItem.createdAt ? book : earliest
}).libraryItem.createdAt }).libraryItem.createdAt
const feedEpisodeObjs = [] const feedEpisodeObjs = []
for (const book of booksWithTracks) { for (const book of books) {
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book) const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book)
for (const track of book.trackList) { for (const track of book.trackList) {
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles)) feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles))

View File

@ -1,4 +1,4 @@
const { DataTypes, Model, where, fn, col } = require('sequelize') const { DataTypes, Model, where, fn, col, literal } = require('sequelize')
const { getTitlePrefixAtEnd } = require('../utils/index') const { getTitlePrefixAtEnd } = require('../utils/index')
@ -20,6 +20,11 @@ class Series extends Model {
this.createdAt this.createdAt
/** @type {Date} */ /** @type {Date} */
this.updatedAt this.updatedAt
// Expanded properties
/** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */
this.books
} }
/** /**
@ -103,6 +108,35 @@ class Series extends Model {
Series.belongsTo(library) Series.belongsTo(library)
} }
/**
* Get all books in collection expanded with library item
*
* @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}
*/
getBooksExpandedWithLibraryItem() {
return this.getBooks({
joinTableAttributes: ['sequence'],
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [[literal('CAST(`bookSeries.sequence` AS FLOAT) ASC NULLS LAST')]]
})
}
toOldJSON() { toOldJSON() {
return { return {
id: this.id, id: this.id,

View File

@ -182,65 +182,6 @@ class Feed {
this.updatedAt = Date.now() this.updatedAt = Date.now()
} }
setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
const feedUrl = `/feed/${slug}`
let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
// Sort series items by series sequence
itemsWithTracks = naturalSort(itemsWithTracks).asc((li) => li.media.metadata.getSeriesSequence(seriesExpanded.id))
const libraryId = itemsWithTracks[0].libraryId
const firstItemWithCover = itemsWithTracks.find((li) => li.media.coverPath)
this.id = uuidv4()
this.slug = slug
this.userId = userId
this.entityType = 'series'
this.entityId = seriesExpanded.id
this.entityUpdatedAt = seriesExpanded.updatedAt // 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 = seriesExpanded.name
this.meta.description = seriesExpanded.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 = `/library/${libraryId}/series/${seriesExpanded.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()
}
updateFromSeries(seriesExpanded) { updateFromSeries(seriesExpanded) {
let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length) let itemsWithTracks = seriesExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
// Sort series items by series sequence // Sort series items by series sequence