mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Refactor Feed model to create new feed for library item
This commit is contained in:
		
							parent
							
								
									c4610e6102
								
							
						
					
					
						commit
						9bd1f9e3d5
					
				| @ -38,38 +38,43 @@ class RSSFeedController { | |||||||
|    * @param {Response} res |    * @param {Response} res | ||||||
|    */ |    */ | ||||||
|   async openRSSFeedForItem(req, res) { |   async openRSSFeedForItem(req, res) { | ||||||
|     const options = req.body || {} |     const reqBody = req.body || {} | ||||||
| 
 | 
 | ||||||
|     const item = await Database.libraryItemModel.getOldById(req.params.itemId) |     const itemExpanded = await Database.libraryItemModel.getExpandedById(req.params.itemId) | ||||||
|     if (!item) return res.sendStatus(404) |     if (!itemExpanded) return res.sendStatus(404) | ||||||
| 
 | 
 | ||||||
|     // Check user can access this library item
 |     // Check user can access this library item
 | ||||||
|     if (!req.user.checkCanAccessLibraryItem(item)) { |     if (!req.user.checkCanAccessLibraryItem(itemExpanded)) { | ||||||
|       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`) |       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) |       return res.sendStatus(403) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // 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 item has audio tracks
 |     // Check item has audio tracks
 | ||||||
|     if (!item.media.numTracks) { |     if (!itemExpanded.hasAudioTracks()) { | ||||||
|       Logger.error(`[RSSFeedController] Cannot open RSS feed for item "${item.media.metadata.title}" because it has no audio tracks`) |       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') |       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)
 |     // 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 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({ |     res.json({ | ||||||
|       feed: feed.toJSONMinified() |       feed: feed.toOldJSONMinified() | ||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -223,25 +223,42 @@ class RssFeedManager { | |||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|    * |    * | ||||||
|    * @param {string} userId |  | ||||||
|    * @param {*} libraryItem |  | ||||||
|    * @param {*} options |    * @param {*} options | ||||||
|    * @returns |    * @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 {import('../models/LibraryItem')} libraryItem | ||||||
|  |    * @param {*} options | ||||||
|  |    * @returns {Promise<import('../models/Feed').FeedExpanded>} | ||||||
|    */ |    */ | ||||||
|   async openFeedForItem(userId, libraryItem, options) { |   async openFeedForItem(userId, libraryItem, 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 item ${libraryItem.id} "${libraryItem.media.title}"`) | ||||||
|     feed.setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing, ownerName, ownerEmail) |     const feedExpanded = await Database.feedModel.createFeedForLibraryItem(userId, libraryItem, 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 | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|  | |||||||
| @ -106,6 +106,9 @@ class Book extends Model { | |||||||
|     this.updatedAt |     this.updatedAt | ||||||
|     /** @type {Date} */ |     /** @type {Date} */ | ||||||
|     this.createdAt |     this.createdAt | ||||||
|  | 
 | ||||||
|  |     /** @type {import('./Author')[]} - optional if expanded */ | ||||||
|  |     this.authors | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   static getOldBook(libraryItemExpanded) { |   static getOldBook(libraryItemExpanded) { | ||||||
| @ -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 | module.exports = Book | ||||||
|  | |||||||
| @ -1,7 +1,22 @@ | |||||||
|  | const Path = require('path') | ||||||
| const { DataTypes, Model } = require('sequelize') | const { DataTypes, Model } = require('sequelize') | ||||||
| const oldFeed = require('../objects/Feed') | const oldFeed = require('../objects/Feed') | ||||||
| const areEquivalent = require('../utils/areEquivalent') | 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 { | class Feed extends Model { | ||||||
|   constructor(values, options) { |   constructor(values, options) { | ||||||
|     super(values, options) |     super(values, options) | ||||||
| @ -50,6 +65,9 @@ class Feed extends Model { | |||||||
|     this.createdAt |     this.createdAt | ||||||
|     /** @type {Date} */ |     /** @type {Date} */ | ||||||
|     this.updatedAt |     this.updatedAt | ||||||
|  | 
 | ||||||
|  |     /** @type {import('./FeedEpisode')[]} - only set if expanded */ | ||||||
|  |     this.feedEpisodes | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   static async getOldFeeds() { |   static async getOldFeeds() { | ||||||
| @ -67,7 +85,15 @@ class Feed extends Model { | |||||||
|    * @returns {oldFeed} |    * @returns {oldFeed} | ||||||
|    */ |    */ | ||||||
|   static getOldFeed(feedExpanded) { |   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({ |     return new oldFeed({ | ||||||
|       id: feedExpanded.id, |       id: feedExpanded.id, | ||||||
|       slug: feedExpanded.slug, |       slug: feedExpanded.slug, | ||||||
| @ -92,7 +118,7 @@ class Feed extends Model { | |||||||
|       }, |       }, | ||||||
|       serverAddress: feedExpanded.serverAddress, |       serverAddress: feedExpanded.serverAddress, | ||||||
|       feedUrl: feedExpanded.feedURL, |       feedUrl: feedExpanded.feedURL, | ||||||
|       episodes: episodes || [], |       episodes, | ||||||
|       createdAt: feedExpanded.createdAt.valueOf(), |       createdAt: feedExpanded.createdAt.valueOf(), | ||||||
|       updatedAt: feedExpanded.updatedAt.valueOf() |       updatedAt: feedExpanded.updatedAt.valueOf() | ||||||
|     }) |     }) | ||||||
| @ -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)}` |    * @param {string} userId | ||||||
|     return this[mixinMethodName](options) |    * @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 | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
| @ -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 | module.exports = Feed | ||||||
|  | |||||||
| @ -1,4 +1,8 @@ | |||||||
|  | const Path = require('path') | ||||||
| const { DataTypes, Model } = require('sequelize') | const { DataTypes, Model } = require('sequelize') | ||||||
|  | const uuidv4 = require('uuid').v4 | ||||||
|  | const Logger = require('../Logger') | ||||||
|  | const date = require('../libs/dateAndTime') | ||||||
| 
 | 
 | ||||||
| class FeedEpisode extends Model { | class FeedEpisode extends Model { | ||||||
|   constructor(values, options) { |   constructor(values, options) { | ||||||
| @ -40,29 +44,6 @@ class FeedEpisode extends Model { | |||||||
|     this.updatedAt |     this.updatedAt | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   getOldEpisode() { |  | ||||||
|     const enclosure = { |  | ||||||
|       url: this.enclosureURL, |  | ||||||
|       size: this.enclosureSize, |  | ||||||
|       type: this.enclosureType |  | ||||||
|     } |  | ||||||
|     return { |  | ||||||
|       id: this.id, |  | ||||||
|       title: this.title, |  | ||||||
|       description: this.description, |  | ||||||
|       enclosure, |  | ||||||
|       pubDate: this.pubDate, |  | ||||||
|       link: this.siteURL, |  | ||||||
|       author: this.author, |  | ||||||
|       explicit: this.explicit, |  | ||||||
|       duration: this.duration, |  | ||||||
|       season: this.season, |  | ||||||
|       episode: this.episode, |  | ||||||
|       episodeType: this.episodeType, |  | ||||||
|       fullPath: this.filePath |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   /** |   /** | ||||||
|    * Create feed episode from old model |    * Create feed episode from old model | ||||||
|    * |    * | ||||||
| @ -96,6 +77,144 @@ class FeedEpisode extends Model { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   /** | ||||||
|  |    * | ||||||
|  |    * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded | ||||||
|  |    * @param {import('./Feed')} feed | ||||||
|  |    * @param {string} slug | ||||||
|  |    * @param {import('./PodcastEpisode')} episode | ||||||
|  |    */ | ||||||
|  |   static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) { | ||||||
|  |     return { | ||||||
|  |       title: episode.title, | ||||||
|  |       author: feed.author, | ||||||
|  |       description: episode.description, | ||||||
|  |       siteURL: feed.siteURL, | ||||||
|  |       enclosureURL: `/feed/${slug}/item/${episode.id}/media${Path.extname(episode.audioFile.metadata.filename)}`, | ||||||
|  |       enclosureType: episode.audioFile.mimeType, | ||||||
|  |       enclosureSize: episode.audioFile.metadata.size, | ||||||
|  |       pubDate: episode.pubDate, | ||||||
|  |       season: episode.season, | ||||||
|  |       episode: episode.episode, | ||||||
|  |       episodeType: episode.episodeType, | ||||||
|  |       duration: episode.audioFile.duration, | ||||||
|  |       filePath: episode.audioFile.metadata.path, | ||||||
|  |       explicit: libraryItemExpanded.media.explicit, | ||||||
|  |       feedId: feed.id | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * | ||||||
|  |    * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded | ||||||
|  |    * @param {import('./Feed')} feed | ||||||
|  |    * @param {string} slug | ||||||
|  |    * @param {import('sequelize').Transaction} transaction | ||||||
|  |    * @returns {Promise<FeedEpisode[]>} | ||||||
|  |    */ | ||||||
|  |   static async createFromPodcastEpisodes(libraryItemExpanded, feed, slug, transaction) { | ||||||
|  |     const feedEpisodeObjs = [] | ||||||
|  | 
 | ||||||
|  |     // Sort podcastEpisodes by pubDate. episodic is newest to oldest. serial is oldest to newest.
 | ||||||
|  |     if (feed.podcastType === 'episodic') { | ||||||
|  |       libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate)) | ||||||
|  |     } else { | ||||||
|  |       libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate)) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     for (const episode of libraryItemExpanded.media.podcastEpisodes) { | ||||||
|  |       feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode)) | ||||||
|  |     } | ||||||
|  |     Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`) | ||||||
|  |     return this.bulkCreate(feedEpisodeObjs, { transaction }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names | ||||||
|  |    * | ||||||
|  |    * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded | ||||||
|  |    * @returns {boolean} | ||||||
|  |    */ | ||||||
|  |   static checkUseChapterTitlesForEpisodes(libraryItemExpanded) { | ||||||
|  |     const tracks = libraryItemExpanded.media.trackList || [] | ||||||
|  |     const chapters = libraryItemExpanded.media.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) { | ||||||
|  |         return false | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return true | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * | ||||||
|  |    * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded | ||||||
|  |    * @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) { | ||||||
|  |     // 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 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 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 audiobook is a single file, use book title instead of chapter/file title
 | ||||||
|  |       title = media.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) | ||||||
|  |         if (matchingChapter?.title) title = matchingChapter.title | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |       id: episodeId, | ||||||
|  |       title, | ||||||
|  |       author: feed.author, | ||||||
|  |       description: media.description || '', | ||||||
|  |       siteURL: feed.siteURL, | ||||||
|  |       enclosureURL: contentUrl, | ||||||
|  |       enclosureType: audioTrack.mimeType, | ||||||
|  |       enclosureSize: audioTrack.metadata.size, | ||||||
|  |       pubDate: audiobookPubDate, | ||||||
|  |       duration: audioTrack.duration, | ||||||
|  |       filePath: audioTrack.metadata.path, | ||||||
|  |       explicit: media.explicit, | ||||||
|  |       feedId: feed.id | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * | ||||||
|  |    * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded | ||||||
|  |    * @param {import('./Feed')} feed | ||||||
|  |    * @param {string} slug | ||||||
|  |    * @param {import('sequelize').Transaction} transaction | ||||||
|  |    * @returns {Promise<FeedEpisode[]>} | ||||||
|  |    */ | ||||||
|  |   static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) { | ||||||
|  |     const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded) | ||||||
|  | 
 | ||||||
|  |     const feedEpisodeObjs = [] | ||||||
|  |     for (const track of libraryItemExpanded.media.trackList) { | ||||||
|  |       feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded, feed, slug, track, useChapterTitles)) | ||||||
|  |     } | ||||||
|  |     Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`) | ||||||
|  |     return this.bulkCreate(feedEpisodeObjs, { transaction }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   /** |   /** | ||||||
|    * Initialize model |    * Initialize model | ||||||
|    * @param {import('../Database').sequelize} sequelize |    * @param {import('../Database').sequelize} sequelize | ||||||
| @ -136,6 +255,29 @@ class FeedEpisode extends Model { | |||||||
|     }) |     }) | ||||||
|     FeedEpisode.belongsTo(feed) |     FeedEpisode.belongsTo(feed) | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   getOldEpisode() { | ||||||
|  |     const enclosure = { | ||||||
|  |       url: this.enclosureURL, | ||||||
|  |       size: this.enclosureSize, | ||||||
|  |       type: this.enclosureType | ||||||
|  |     } | ||||||
|  |     return { | ||||||
|  |       id: this.id, | ||||||
|  |       title: this.title, | ||||||
|  |       description: this.description, | ||||||
|  |       enclosure, | ||||||
|  |       pubDate: this.pubDate, | ||||||
|  |       link: this.siteURL, | ||||||
|  |       author: this.author, | ||||||
|  |       explicit: this.explicit, | ||||||
|  |       duration: this.duration, | ||||||
|  |       season: this.season, | ||||||
|  |       episode: this.episode, | ||||||
|  |       episodeType: this.episodeType, | ||||||
|  |       fullPath: this.filePath | ||||||
|  |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| module.exports = FeedEpisode | module.exports = FeedEpisode | ||||||
|  | |||||||
| @ -73,6 +73,9 @@ class LibraryItem extends Model { | |||||||
|     this.createdAt |     this.createdAt | ||||||
|     /** @type {Date} */ |     /** @type {Date} */ | ||||||
|     this.updatedAt |     this.updatedAt | ||||||
|  | 
 | ||||||
|  |     /** @type {Book.BookExpanded|Podcast.PodcastExpanded} - only set when expanded */ | ||||||
|  |     this.media | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
| @ -1124,6 +1127,24 @@ class LibraryItem extends Model { | |||||||
|       } |       } | ||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Check if book or podcast library item has audio tracks | ||||||
|  |    * Requires expanded library item | ||||||
|  |    * | ||||||
|  |    * @returns {boolean} | ||||||
|  |    */ | ||||||
|  |   hasAudioTracks() { | ||||||
|  |     if (!this.media) { | ||||||
|  |       Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`) | ||||||
|  |       return false | ||||||
|  |     } | ||||||
|  |     if (this.mediaType === 'book') { | ||||||
|  |       return this.media.audioFiles?.length > 0 | ||||||
|  |     } else { | ||||||
|  |       return this.media.podcastEpisodes?.length > 0 | ||||||
|  |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| module.exports = LibraryItem | module.exports = LibraryItem | ||||||
|  | |||||||
| @ -101,64 +101,6 @@ class Feed { | |||||||
|     return true |     return true | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   setFromItem(userId, slug, libraryItem, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { |  | ||||||
|     const media = libraryItem.media |  | ||||||
|     const mediaMetadata = media.metadata |  | ||||||
|     const isPodcast = libraryItem.mediaType === 'podcast' |  | ||||||
| 
 |  | ||||||
|     const feedUrl = `/feed/${slug}` |  | ||||||
|     const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName |  | ||||||
| 
 |  | ||||||
|     this.id = uuidv4() |  | ||||||
|     this.slug = slug |  | ||||||
|     this.userId = userId |  | ||||||
|     this.entityType = 'libraryItem' |  | ||||||
|     this.entityId = libraryItem.id |  | ||||||
|     this.entityUpdatedAt = libraryItem.updatedAt |  | ||||||
|     this.coverPath = media.coverPath || null |  | ||||||
|     this.serverAddress = serverAddress |  | ||||||
|     this.feedUrl = feedUrl |  | ||||||
| 
 |  | ||||||
|     const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null |  | ||||||
| 
 |  | ||||||
|     this.meta = new FeedMeta() |  | ||||||
|     this.meta.title = mediaMetadata.title |  | ||||||
|     this.meta.description = mediaMetadata.description |  | ||||||
|     this.meta.author = author |  | ||||||
|     this.meta.imageUrl = media.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png` |  | ||||||
|     this.meta.feedUrl = feedUrl |  | ||||||
|     this.meta.link = `/item/${libraryItem.id}` |  | ||||||
|     this.meta.explicit = !!mediaMetadata.explicit |  | ||||||
|     this.meta.type = mediaMetadata.type |  | ||||||
|     this.meta.language = mediaMetadata.language |  | ||||||
|     this.meta.preventIndexing = preventIndexing |  | ||||||
|     this.meta.ownerName = ownerName |  | ||||||
|     this.meta.ownerEmail = ownerEmail |  | ||||||
| 
 |  | ||||||
|     this.episodes = [] |  | ||||||
|     if (isPodcast) { |  | ||||||
|       // PODCAST EPISODES
 |  | ||||||
|       media.episodes.forEach((episode) => { |  | ||||||
|         if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt |  | ||||||
| 
 |  | ||||||
|         const feedEpisode = new FeedEpisode() |  | ||||||
|         feedEpisode.setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, this.meta) |  | ||||||
|         this.episodes.push(feedEpisode) |  | ||||||
|       }) |  | ||||||
|     } else { |  | ||||||
|       // AUDIOBOOK EPISODES
 |  | ||||||
|       const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItem) |  | ||||||
|       media.tracks.forEach((audioTrack) => { |  | ||||||
|         const feedEpisode = new FeedEpisode() |  | ||||||
|         feedEpisode.setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, this.meta, useChapterTitles) |  | ||||||
|         this.episodes.push(feedEpisode) |  | ||||||
|       }) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     this.createdAt = Date.now() |  | ||||||
|     this.updatedAt = Date.now() |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   updateFromItem(libraryItem) { |   updateFromItem(libraryItem) { | ||||||
|     const media = libraryItem.media |     const media = libraryItem.media | ||||||
|     const mediaMetadata = media.metadata |     const mediaMetadata = media.metadata | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user