Skip to content

Commit

Permalink
Merge pull request #3721 from advplyr/refactor-feeds-from-item
Browse files Browse the repository at this point in the history
Refactor Feed model to create new feed for library item
  • Loading branch information
advplyr authored Dec 14, 2024
2 parents c4610e6 + 9bd1f9e commit ca2327a
Show file tree
Hide file tree
Showing 7 changed files with 399 additions and 111 deletions.
29 changes: 17 additions & 12 deletions server/controllers/RSSFeedController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
}

Expand Down
41 changes: 29 additions & 12 deletions server/managers/RssFeedManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,27 +221,44 @@ class RssFeedManager {
readStream.pipe(res)
}

/**
*
* @param {*} options
* @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 {*} libraryItem
* @param {import('../models/LibraryItem')} libraryItem
* @param {*} options
* @returns
* @returns {Promise<import('../models/Feed').FeedExpanded>}
*/
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 feed = new Feed()
feed.setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
const feedOptions = this.getFeedOptionsFromReqOptions(options)

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
}

/**
Expand Down
29 changes: 29 additions & 0 deletions server/models/Book.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
144 changes: 138 additions & 6 deletions server/models/Feed.js
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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() {
Expand All @@ -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,
Expand All @@ -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()
})
Expand Down Expand Up @@ -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<FeedExpanded>}
*/
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
}
}

/**
Expand Down Expand Up @@ -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
Loading

0 comments on commit ca2327a

Please sign in to comment.