Skip to content

Commit ca2327a

Browse files
authored
Merge pull request #3721 from advplyr/refactor-feeds-from-item
Refactor Feed model to create new feed for library item
2 parents c4610e6 + 9bd1f9e commit ca2327a

File tree

7 files changed

+399
-111
lines changed

7 files changed

+399
-111
lines changed

server/controllers/RSSFeedController.js

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,38 +38,43 @@ class RSSFeedController {
3838
* @param {Response} res
3939
*/
4040
async openRSSFeedForItem(req, res) {
41-
const options = req.body || {}
41+
const reqBody = req.body || {}
4242

43-
const item = await Database.libraryItemModel.getOldById(req.params.itemId)
44-
if (!item) return res.sendStatus(404)
43+
const itemExpanded = await Database.libraryItemModel.getExpandedById(req.params.itemId)
44+
if (!itemExpanded) return res.sendStatus(404)
4545

4646
// Check user can access this library item
47-
if (!req.user.checkCanAccessLibraryItem(item)) {
48-
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`)
47+
if (!req.user.checkCanAccessLibraryItem(itemExpanded)) {
48+
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`)
4949
return res.sendStatus(403)
5050
}
5151

5252
// Check request body options exist
53-
if (!options.serverAddress || !options.slug) {
53+
if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
5454
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
5555
return res.status(400).send('Invalid request body')
5656
}
5757

5858
// Check item has audio tracks
59-
if (!item.media.numTracks) {
60-
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${item.media.metadata.title}" because it has no audio tracks`)
59+
if (!itemExpanded.hasAudioTracks()) {
60+
Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${itemExpanded.media.title}" because it has no audio tracks`)
6161
return res.status(400).send('Item has no audio tracks')
6262
}
6363

6464
// Check that this slug is not being used for another feed (slug will also be the Feed id)
65-
if (await this.rssFeedManager.findFeedBySlug(options.slug)) {
66-
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`)
65+
if (await this.rssFeedManager.findFeedBySlug(reqBody.slug)) {
66+
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
6767
return res.status(400).send('Slug already in use')
6868
}
6969

70-
const feed = await this.rssFeedManager.openFeedForItem(req.user.id, item, req.body)
70+
const feed = await this.rssFeedManager.openFeedForItem(req.user.id, itemExpanded, reqBody)
71+
if (!feed) {
72+
Logger.error(`[RSSFeedController] Failed to open RSS feed for item "${itemExpanded.media.title}"`)
73+
return res.status(500).send('Failed to open RSS feed')
74+
}
75+
7176
res.json({
72-
feed: feed.toJSONMinified()
77+
feed: feed.toOldJSONMinified()
7378
})
7479
}
7580

server/managers/RssFeedManager.js

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -221,27 +221,44 @@ class RssFeedManager {
221221
readStream.pipe(res)
222222
}
223223

224+
/**
225+
*
226+
* @param {*} options
227+
* @returns {import('../models/Feed').FeedOptions}
228+
*/
229+
getFeedOptionsFromReqOptions(options) {
230+
const metadataDetails = options.metadataDetails || {}
231+
232+
if (metadataDetails.preventIndexing !== false) {
233+
metadataDetails.preventIndexing = true
234+
}
235+
236+
return {
237+
preventIndexing: metadataDetails.preventIndexing,
238+
ownerName: metadataDetails.ownerName && typeof metadataDetails.ownerName === 'string' ? metadataDetails.ownerName : null,
239+
ownerEmail: metadataDetails.ownerEmail && typeof metadataDetails.ownerEmail === 'string' ? metadataDetails.ownerEmail : null
240+
}
241+
}
242+
224243
/**
225244
*
226245
* @param {string} userId
227-
* @param {*} libraryItem
246+
* @param {import('../models/LibraryItem')} libraryItem
228247
* @param {*} options
229-
* @returns
248+
* @returns {Promise<import('../models/Feed').FeedExpanded>}
230249
*/
231250
async openFeedForItem(userId, libraryItem, options) {
232251
const serverAddress = options.serverAddress
233252
const slug = options.slug
234-
const preventIndexing = options.metadataDetails?.preventIndexing ?? true
235-
const ownerName = options.metadataDetails?.ownerName
236-
const ownerEmail = options.metadataDetails?.ownerEmail
237-
238-
const feed = new Feed()
239-
feed.setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail)
253+
const feedOptions = this.getFeedOptionsFromReqOptions(options)
240254

241-
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`)
242-
await Database.createFeed(feed)
243-
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified())
244-
return feed
255+
Logger.info(`[RssFeedManager] Creating RSS feed for item ${libraryItem.id} "${libraryItem.media.title}"`)
256+
const feedExpanded = await Database.feedModel.createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
257+
if (feedExpanded) {
258+
Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
259+
SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
260+
}
261+
return feedExpanded
245262
}
246263

247264
/**

server/models/Book.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ class Book extends Model {
106106
this.updatedAt
107107
/** @type {Date} */
108108
this.createdAt
109+
110+
/** @type {import('./Author')[]} - optional if expanded */
111+
this.authors
109112
}
110113

111114
static getOldBook(libraryItemExpanded) {
@@ -320,6 +323,32 @@ class Book extends Model {
320323
}
321324
)
322325
}
326+
327+
/**
328+
* Comma separated array of author names
329+
* Requires authors to be loaded
330+
*
331+
* @returns {string}
332+
*/
333+
get authorName() {
334+
if (this.authors === undefined) {
335+
Logger.error(`[Book] authorName: Cannot get authorName because authors are not loaded`)
336+
return ''
337+
}
338+
return this.authors.map((au) => au.name).join(', ')
339+
}
340+
get includedAudioFiles() {
341+
return this.audioFiles.filter((af) => !af.exclude)
342+
}
343+
get trackList() {
344+
let startOffset = 0
345+
return this.includedAudioFiles.map((af) => {
346+
const track = structuredClone(af)
347+
track.startOffset = startOffset
348+
startOffset += track.duration
349+
return track
350+
})
351+
}
323352
}
324353

325354
module.exports = Book

server/models/Feed.js

Lines changed: 138 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
1+
const Path = require('path')
12
const { DataTypes, Model } = require('sequelize')
23
const oldFeed = require('../objects/Feed')
34
const areEquivalent = require('../utils/areEquivalent')
45

6+
/**
7+
* @typedef FeedOptions
8+
* @property {boolean} preventIndexing
9+
* @property {string} ownerName
10+
* @property {string} ownerEmail
11+
*/
12+
13+
/**
14+
* @typedef FeedExpandedProperties
15+
* @property {import('./FeedEpisode')} feedEpisodes
16+
*
17+
* @typedef {Feed & FeedExpandedProperties} FeedExpanded
18+
*/
19+
520
class Feed extends Model {
621
constructor(values, options) {
722
super(values, options)
@@ -50,6 +65,9 @@ class Feed extends Model {
5065
this.createdAt
5166
/** @type {Date} */
5267
this.updatedAt
68+
69+
/** @type {import('./FeedEpisode')[]} - only set if expanded */
70+
this.feedEpisodes
5371
}
5472

5573
static async getOldFeeds() {
@@ -67,7 +85,15 @@ class Feed extends Model {
6785
* @returns {oldFeed}
6886
*/
6987
static getOldFeed(feedExpanded) {
70-
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
88+
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) || []
89+
90+
// Sort episodes by pubDate. Newest to oldest for episodic, oldest to newest for serial
91+
if (feedExpanded.podcastType === 'episodic') {
92+
episodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
93+
} else {
94+
episodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate))
95+
}
96+
7197
return new oldFeed({
7298
id: feedExpanded.id,
7399
slug: feedExpanded.slug,
@@ -92,7 +118,7 @@ class Feed extends Model {
92118
},
93119
serverAddress: feedExpanded.serverAddress,
94120
feedUrl: feedExpanded.feedURL,
95-
episodes: episodes || [],
121+
episodes,
96122
createdAt: feedExpanded.createdAt.valueOf(),
97123
updatedAt: feedExpanded.updatedAt.valueOf()
98124
})
@@ -250,10 +276,62 @@ class Feed extends Model {
250276
}
251277
}
252278

253-
getEntity(options) {
254-
if (!this.entityType) return Promise.resolve(null)
255-
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
256-
return this[mixinMethodName](options)
279+
/**
280+
*
281+
* @param {string} userId
282+
* @param {string} slug
283+
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
284+
* @param {string} serverAddress
285+
* @param {FeedOptions} feedOptions
286+
*
287+
* @returns {Promise<FeedExpanded>}
288+
*/
289+
static async createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) {
290+
const media = libraryItem.media
291+
292+
const feedObj = {
293+
slug,
294+
entityType: 'libraryItem',
295+
entityId: libraryItem.id,
296+
entityUpdatedAt: libraryItem.updatedAt,
297+
serverAddress,
298+
feedURL: `/feed/${slug}`,
299+
imageURL: media.coverPath ? `/feed/${slug}/cover${Path.extname(media.coverPath)}` : `/Logo.png`,
300+
siteURL: `/item/${libraryItem.id}`,
301+
title: media.title,
302+
description: media.description,
303+
author: libraryItem.mediaType === 'podcast' ? media.author : media.authorName,
304+
podcastType: libraryItem.mediaType === 'podcast' ? media.podcastType : 'serial',
305+
language: media.language,
306+
preventIndexing: feedOptions.preventIndexing,
307+
ownerName: feedOptions.ownerName,
308+
ownerEmail: feedOptions.ownerEmail,
309+
explicit: media.explicit,
310+
coverPath: media.coverPath,
311+
userId
312+
}
313+
314+
/** @type {typeof import('./FeedEpisode')} */
315+
const feedEpisodeModel = this.sequelize.models.feedEpisode
316+
317+
const transaction = await this.sequelize.transaction()
318+
try {
319+
const feed = await this.create(feedObj, { transaction })
320+
321+
if (libraryItem.mediaType === 'podcast') {
322+
feed.feedEpisodes = await feedEpisodeModel.createFromPodcastEpisodes(libraryItem, feed, slug, transaction)
323+
} else {
324+
feed.feedEpisodes = await feedEpisodeModel.createFromAudiobookTracks(libraryItem, feed, slug, transaction)
325+
}
326+
327+
await transaction.commit()
328+
329+
return feed
330+
} catch (error) {
331+
Logger.error(`[Feed] Error creating feed for library item ${libraryItem.id}`, error)
332+
await transaction.rollback()
333+
return null
334+
}
257335
}
258336

259337
/**
@@ -369,6 +447,60 @@ class Feed extends Model {
369447
}
370448
})
371449
}
450+
451+
getEntity(options) {
452+
if (!this.entityType) return Promise.resolve(null)
453+
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
454+
return this[mixinMethodName](options)
455+
}
456+
457+
toOldJSON() {
458+
const episodes = this.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
459+
return {
460+
id: this.id,
461+
slug: this.slug,
462+
userId: this.userId,
463+
entityType: this.entityType,
464+
entityId: this.entityId,
465+
entityUpdatedAt: this.entityUpdatedAt?.valueOf() || null,
466+
coverPath: this.coverPath || null,
467+
meta: {
468+
title: this.title,
469+
description: this.description,
470+
author: this.author,
471+
imageUrl: this.imageURL,
472+
feedUrl: this.feedURL,
473+
link: this.siteURL,
474+
explicit: this.explicit,
475+
type: this.podcastType,
476+
language: this.language,
477+
preventIndexing: this.preventIndexing,
478+
ownerName: this.ownerName,
479+
ownerEmail: this.ownerEmail
480+
},
481+
serverAddress: this.serverAddress,
482+
feedUrl: this.feedURL,
483+
episodes: episodes || [],
484+
createdAt: this.createdAt.valueOf(),
485+
updatedAt: this.updatedAt.valueOf()
486+
}
487+
}
488+
489+
toOldJSONMinified() {
490+
return {
491+
id: this.id,
492+
entityType: this.entityType,
493+
entityId: this.entityId,
494+
feedUrl: this.feedURL,
495+
meta: {
496+
title: this.title,
497+
description: this.description,
498+
preventIndexing: this.preventIndexing,
499+
ownerName: this.ownerName,
500+
ownerEmail: this.ownerEmail
501+
}
502+
}
503+
}
372504
}
373505

374506
module.exports = Feed

0 commit comments

Comments
 (0)