Refactor Feed model to create new feed for collection

This commit is contained in:
advplyr 2024-12-15 10:53:31 -06:00
parent ca2327aba3
commit d576625cb7
7 changed files with 171 additions and 93 deletions

View File

@ -87,35 +87,39 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async openRSSFeedForCollection(req, res) { async openRSSFeedForCollection(req, res) {
const options = req.body || {} const reqBody = req.body || {}
const collection = await Database.collectionModel.findByPk(req.params.collectionId) const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) return res.sendStatus(404) if (!collection) 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 collectionExpanded = await collection.getOldJsonExpanded() collection.books = await collection.getBooksExpandedWithLibraryItem()
const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length)
// Check collection has audio tracks // 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`) 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') 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({ res.json({
feed: feed.toJSONMinified() feed: feed.toOldJSONMinified()
}) })
} }

View File

@ -264,24 +264,22 @@ class RssFeedManager {
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {*} collectionExpanded * @param {import('../models/Collection')} collectionExpanded
* @param {*} options * @param {*} options
* @returns * @returns {Promise<import('../models/Feed').FeedExpanded>}
*/ */
async openFeedForCollection(userId, collectionExpanded, options) { async openFeedForCollection(userId, collectionExpanded, 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 collection "${collectionExpanded.name}"`)
feed.setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail) const feedExpanded = await Database.feedModel.createFeedForCollection(userId, collectionExpanded, 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
} }
/** /**

View File

@ -29,6 +29,12 @@ const Logger = require('../Logger')
* @property {SeriesExpanded[]} series * @property {SeriesExpanded[]} series
* *
* @typedef {Book & BookExpandedProperties} BookExpanded * @typedef {Book & BookExpandedProperties} BookExpanded
*
* Collections use BookExpandedWithLibraryItem
* @typedef BookExpandedWithLibraryItemProperties
* @property {import('./LibraryItem')} libraryItem
*
* @typedef {BookExpanded & BookExpandedWithLibraryItemProperties} BookExpandedWithLibraryItem
*/ */
/** /**

View File

@ -1,6 +1,7 @@
const { DataTypes, Model, Sequelize } = require('sequelize') const { DataTypes, Model, Sequelize } = require('sequelize')
const oldCollection = require('../objects/Collection') const oldCollection = require('../objects/Collection')
const Logger = require('../Logger')
class Collection extends Model { class Collection extends Model {
constructor(values, options) { constructor(values, options) {
@ -18,6 +19,11 @@ class Collection extends Model {
this.updatedAt this.updatedAt
/** @type {Date} */ /** @type {Date} */
this.createdAt 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) Collection.belongsTo(library)
} }
/**
* Get all books in collection expanded with library item
*
* @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}
*/
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 * Get old collection toJSONExpanded, items filtered for user permissions
* *

View File

@ -279,8 +279,8 @@ class Feed extends Model {
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {string} slug
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem * @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
* @param {string} slug
* @param {string} serverAddress * @param {string} serverAddress
* @param {FeedOptions} feedOptions * @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<FeedExpanded>}
*/
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 * Initialize model
* *

View File

@ -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 * 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} * @returns {boolean}
*/ */
static checkUseChapterTitlesForEpisodes(libraryItemExpanded) { static checkUseChapterTitlesForEpisodes(book) {
const tracks = libraryItemExpanded.media.trackList || [] const tracks = book.trackList || []
const chapters = libraryItemExpanded.media.chapters || [] const chapters = book.chapters || []
if (tracks.length !== chapters.length) return false if (tracks.length !== chapters.length) return false
for (let i = 0; i < tracks.length; i++) { for (let i = 0; i < tracks.length; i++) {
if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) { 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 {import('./Feed')} feed
* @param {string} slug * @param {string} slug
* @param {import('./Book').AudioFileObject} audioTrack * @param {import('./Book').AudioFileObject} audioTrack
* @param {boolean} useChapterTitles * @param {boolean} useChapterTitles
* @param {string} [pubDateOverride]
*/ */
static getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded, feed, slug, audioTrack, useChapterTitles, pubDateOverride = null) { static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate> // Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
let 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
let episodeId = uuidv4() let episodeId = uuidv4()
// e.g. Track 1 will have a pub date before Track 2 // 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 contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}`
const media = libraryItemExpanded.media
let title = audioTrack.title 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 // If audiobook is a single file, use book title instead of chapter/file title
title = media.title title = book.title
} else { } else {
if (useChapterTitles) { if (useChapterTitles) {
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title // 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 if (matchingChapter?.title) title = matchingChapter.title
} }
} }
@ -183,7 +182,7 @@ class FeedEpisode extends Model {
id: episodeId, id: episodeId,
title, title,
author: feed.author, author: feed.author,
description: media.description || '', description: book.description || '',
siteURL: feed.siteURL, siteURL: feed.siteURL,
enclosureURL: contentUrl, enclosureURL: contentUrl,
enclosureType: audioTrack.mimeType, enclosureType: audioTrack.mimeType,
@ -191,7 +190,7 @@ class FeedEpisode extends Model {
pubDate: audiobookPubDate, pubDate: audiobookPubDate,
duration: audioTrack.duration, duration: audioTrack.duration,
filePath: audioTrack.metadata.path, filePath: audioTrack.metadata.path,
explicit: media.explicit, explicit: book.explicit,
feedId: feed.id feedId: feed.id
} }
} }
@ -205,11 +204,37 @@ class FeedEpisode extends Model {
* @returns {Promise<FeedEpisode[]>} * @returns {Promise<FeedEpisode[]>}
*/ */
static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) { static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) {
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded) const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded.media)
const feedEpisodeObjs = [] const feedEpisodeObjs = []
for (const track of libraryItemExpanded.media.trackList) { 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<FeedEpisode[]>}
*/
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}`) Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
return this.bulkCreate(feedEpisodeObjs, { transaction }) return this.bulkCreate(feedEpisodeObjs, { transaction })

View File

@ -143,61 +143,6 @@ class Feed {
this.updatedAt = Date.now() 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) { updateFromCollection(collectionExpanded) {
const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length) const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath) const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)