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 collection
This commit is contained in:
		
							parent
							
								
									ca2327aba3
								
							
						
					
					
						commit
						d576625cb7
					
				| @ -87,35 +87,39 @@ class RSSFeedController { | ||||
|    * @param {Response} res | ||||
|    */ | ||||
|   async openRSSFeedForCollection(req, res) { | ||||
|     const options = req.body || {} | ||||
|     const reqBody = req.body || {} | ||||
| 
 | ||||
|     const collection = await Database.collectionModel.findByPk(req.params.collectionId) | ||||
|     if (!collection) return res.sendStatus(404) | ||||
| 
 | ||||
|     // 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 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 collectionExpanded = await collection.getOldJsonExpanded() | ||||
|     const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length) | ||||
|     collection.books = await collection.getBooksExpandedWithLibraryItem() | ||||
| 
 | ||||
|     // 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`) | ||||
|       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({ | ||||
|       feed: feed.toJSONMinified() | ||||
|       feed: feed.toOldJSONMinified() | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|  | ||||
| @ -264,24 +264,22 @@ class RssFeedManager { | ||||
|   /** | ||||
|    * | ||||
|    * @param {string} userId | ||||
|    * @param {*} collectionExpanded | ||||
|    * @param {import('../models/Collection')} collectionExpanded | ||||
|    * @param {*} options | ||||
|    * @returns | ||||
|    * @returns {Promise<import('../models/Feed').FeedExpanded>} | ||||
|    */ | ||||
|   async openFeedForCollection(userId, collectionExpanded, 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 feedOptions = this.getFeedOptionsFromReqOptions(options) | ||||
| 
 | ||||
|     const feed = new Feed() | ||||
|     feed.setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail) | ||||
| 
 | ||||
|     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 collection "${collectionExpanded.name}"`) | ||||
|     const feedExpanded = await Database.feedModel.createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions) | ||||
|     if (feedExpanded) { | ||||
|       Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`) | ||||
|       SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified()) | ||||
|     } | ||||
|     return feedExpanded | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|  | ||||
| @ -29,6 +29,12 @@ const Logger = require('../Logger') | ||||
|  * @property {SeriesExpanded[]} series | ||||
|  * | ||||
|  * @typedef {Book & BookExpandedProperties} BookExpanded | ||||
|  * | ||||
|  * Collections use BookExpandedWithLibraryItem | ||||
|  * @typedef BookExpandedWithLibraryItemProperties | ||||
|  * @property {import('./LibraryItem')} libraryItem | ||||
|  * | ||||
|  * @typedef {BookExpanded & BookExpandedWithLibraryItemProperties} BookExpandedWithLibraryItem | ||||
|  */ | ||||
| 
 | ||||
| /** | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| const { DataTypes, Model, Sequelize } = require('sequelize') | ||||
| 
 | ||||
| const oldCollection = require('../objects/Collection') | ||||
| const Logger = require('../Logger') | ||||
| 
 | ||||
| class Collection extends Model { | ||||
|   constructor(values, options) { | ||||
| @ -18,6 +19,11 @@ class Collection extends Model { | ||||
|     this.updatedAt | ||||
|     /** @type {Date} */ | ||||
|     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) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * 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 | ||||
|    * | ||||
|  | ||||
| @ -279,8 +279,8 @@ class Feed extends Model { | ||||
|   /** | ||||
|    * | ||||
|    * @param {string} userId | ||||
|    * @param {string} slug | ||||
|    * @param {import('./LibraryItem').LibraryItemExpanded} libraryItem | ||||
|    * @param {string} slug | ||||
|    * @param {string} serverAddress | ||||
|    * @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 | ||||
|    * | ||||
|  | ||||
| @ -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 | ||||
|    * | ||||
|    * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded | ||||
|    * @param {import('./Book')} book | ||||
|    * @returns {boolean} | ||||
|    */ | ||||
|   static checkUseChapterTitlesForEpisodes(libraryItemExpanded) { | ||||
|     const tracks = libraryItemExpanded.media.trackList || [] | ||||
|     const chapters = libraryItemExpanded.media.chapters || [] | ||||
|   static checkUseChapterTitlesForEpisodes(book) { | ||||
|     const tracks = book.trackList || [] | ||||
|     const chapters = book.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) { | ||||
| @ -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 {string} slug | ||||
|    * @param {import('./Book').AudioFileObject} audioTrack | ||||
|    * @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>
 | ||||
|     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 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 media = libraryItemExpanded.media | ||||
| 
 | ||||
|     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
 | ||||
|       title = media.title | ||||
|       title = book.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) | ||||
|         const matchingChapter = book.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1) | ||||
|         if (matchingChapter?.title) title = matchingChapter.title | ||||
|       } | ||||
|     } | ||||
| @ -183,7 +182,7 @@ class FeedEpisode extends Model { | ||||
|       id: episodeId, | ||||
|       title, | ||||
|       author: feed.author, | ||||
|       description: media.description || '', | ||||
|       description: book.description || '', | ||||
|       siteURL: feed.siteURL, | ||||
|       enclosureURL: contentUrl, | ||||
|       enclosureType: audioTrack.mimeType, | ||||
| @ -191,7 +190,7 @@ class FeedEpisode extends Model { | ||||
|       pubDate: audiobookPubDate, | ||||
|       duration: audioTrack.duration, | ||||
|       filePath: audioTrack.metadata.path, | ||||
|       explicit: media.explicit, | ||||
|       explicit: book.explicit, | ||||
|       feedId: feed.id | ||||
|     } | ||||
|   } | ||||
| @ -205,11 +204,37 @@ class FeedEpisode extends Model { | ||||
|    * @returns {Promise<FeedEpisode[]>} | ||||
|    */ | ||||
|   static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) { | ||||
|     const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded) | ||||
|     const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded.media) | ||||
| 
 | ||||
|     const feedEpisodeObjs = [] | ||||
|     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}`) | ||||
|     return this.bulkCreate(feedEpisodeObjs, { transaction }) | ||||
|  | ||||
| @ -143,61 +143,6 @@ class Feed { | ||||
|     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) { | ||||
|     const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length) | ||||
|     const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath) | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user