mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Add:RSS feed for series & cleanup empty series from db #1265
This commit is contained in:
		
							parent
							
								
									a364fe5031
								
							
						
					
					
						commit
						70ba2f7850
					
				| @ -51,6 +51,11 @@ | |||||||
|         <div class="flex-grow" /> |         <div class="flex-grow" /> | ||||||
|         <ui-checkbox v-if="!isBatchSelecting" v-model="settings.collapseBookSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseBookSeries" /> |         <ui-checkbox v-if="!isBatchSelecting" v-model="settings.collapseBookSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseBookSeries" /> | ||||||
| 
 | 
 | ||||||
|  |         <!-- RSS feed --> | ||||||
|  |         <ui-tooltip v-if="seriesRssFeed" :text="$strings.LabelOpenRSSFeed" direction="top"> | ||||||
|  |           <ui-icon-btn icon="rss_feed" class="mx-0.5" :size="7" icon-font-size="1.2rem" bg-color="success" outlined @click="showOpenSeriesRSSFeed" /> | ||||||
|  |         </ui-tooltip> | ||||||
|  | 
 | ||||||
|         <ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" /> |         <ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" /> | ||||||
|       </template> |       </template> | ||||||
|       <!-- library & collections page --> |       <!-- library & collections page --> | ||||||
| @ -229,6 +234,9 @@ export default { | |||||||
|     seriesProgress() { |     seriesProgress() { | ||||||
|       return this.selectedSeries ? this.selectedSeries.progress : null |       return this.selectedSeries ? this.selectedSeries.progress : null | ||||||
|     }, |     }, | ||||||
|  |     seriesRssFeed() { | ||||||
|  |       return this.selectedSeries ? this.selectedSeries.rssFeed : null | ||||||
|  |     }, | ||||||
|     seriesLibraryItemIds() { |     seriesLibraryItemIds() { | ||||||
|       if (!this.seriesProgress) return [] |       if (!this.seriesProgress) return [] | ||||||
|       return this.seriesProgress.libraryItemIds || [] |       return this.seriesProgress.libraryItemIds || [] | ||||||
| @ -253,7 +261,7 @@ export default { | |||||||
|   methods: { |   methods: { | ||||||
|     seriesContextMenuAction(action) { |     seriesContextMenuAction(action) { | ||||||
|       if (action === 'open-rss-feed') { |       if (action === 'open-rss-feed') { | ||||||
|         // TODO: Open RSS Feed |         this.showOpenSeriesRSSFeed() | ||||||
|       } else if (action === 're-add-to-continue-listening') { |       } else if (action === 're-add-to-continue-listening') { | ||||||
|         if (this.processingSeries) { |         if (this.processingSeries) { | ||||||
|           console.warn('Already processing series') |           console.warn('Already processing series') | ||||||
| @ -268,6 +276,14 @@ export default { | |||||||
|         this.markSeriesFinished() |         this.markSeriesFinished() | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     showOpenSeriesRSSFeed() { | ||||||
|  |       this.$store.commit('globals/setRSSFeedOpenCloseModal', { | ||||||
|  |         id: this.selectedSeries.id, | ||||||
|  |         name: this.selectedSeries.name, | ||||||
|  |         type: 'series', | ||||||
|  |         feed: this.selectedSeries.rssFeed | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|     reAddSeriesToContinueListening() { |     reAddSeriesToContinueListening() { | ||||||
|       this.processingSeries = true |       this.processingSeries = true | ||||||
|       this.$axios |       this.$axios | ||||||
| @ -396,16 +412,32 @@ export default { | |||||||
|     }, |     }, | ||||||
|     setBookshelfTotalEntities(totalEntities) { |     setBookshelfTotalEntities(totalEntities) { | ||||||
|       this.totalEntities = totalEntities |       this.totalEntities = totalEntities | ||||||
|  |     }, | ||||||
|  |     rssFeedOpen(data) { | ||||||
|  |       if (data.entityId === this.seriesId) { | ||||||
|  |         console.log('RSS Feed Opened', data) | ||||||
|  |         this.selectedSeries.rssFeed = data | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     rssFeedClosed(data) { | ||||||
|  |       if (data.entityId === this.seriesId) { | ||||||
|  |         console.log('RSS Feed Closed', data) | ||||||
|  |         this.selectedSeries.rssFeed = null | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   mounted() { |   mounted() { | ||||||
|     this.init() |     this.init() | ||||||
|     this.$eventBus.$on('user-settings', this.settingsUpdated) |     this.$eventBus.$on('user-settings', this.settingsUpdated) | ||||||
|     this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities) |     this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities) | ||||||
|  |     this.$root.socket.on('rss_feed_open', this.rssFeedOpen) | ||||||
|  |     this.$root.socket.on('rss_feed_closed', this.rssFeedClosed) | ||||||
|   }, |   }, | ||||||
|   beforeDestroy() { |   beforeDestroy() { | ||||||
|     this.$eventBus.$off('user-settings', this.settingsUpdated) |     this.$eventBus.$off('user-settings', this.settingsUpdated) | ||||||
|     this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities) |     this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities) | ||||||
|  |     this.$root.socket.off('rss_feed_open', this.rssFeedOpen) | ||||||
|  |     this.$root.socket.off('rss_feed_closed', this.rssFeedClosed) | ||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  | |||||||
| @ -13,6 +13,8 @@ | |||||||
|       <p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p> |       <p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p> | ||||||
|     </div> |     </div> | ||||||
| 
 | 
 | ||||||
|  |     <span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span> | ||||||
|  | 
 | ||||||
|     <div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }"> |     <div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }"> | ||||||
|       <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }"> |       <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }"> | ||||||
|         <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p> |         <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p> | ||||||
| @ -125,6 +127,9 @@ export default { | |||||||
|     isAlternativeBookshelfView() { |     isAlternativeBookshelfView() { | ||||||
|       const constants = this.$constants || this.$nuxt.$constants |       const constants = this.$constants || this.$nuxt.$constants | ||||||
|       return this.bookshelfView == constants.BookshelfView.DETAIL |       return this.bookshelfView == constants.BookshelfView.DETAIL | ||||||
|  |     }, | ||||||
|  |     rssFeed() { | ||||||
|  |       return this.series ? this.series.rssFeed : null | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  | |||||||
| @ -330,12 +330,6 @@ export default { | |||||||
|       } |       } | ||||||
|       this.$store.commit('libraries/removeUserPlaylist', playlist) |       this.$store.commit('libraries/removeUserPlaylist', playlist) | ||||||
|     }, |     }, | ||||||
|     rssFeedOpen(data) { |  | ||||||
|       console.log('RSS Feed Open', data) |  | ||||||
|     }, |  | ||||||
|     rssFeedClosed(data) { |  | ||||||
|       console.log('RSS Feed Closed', data) |  | ||||||
|     }, |  | ||||||
|     backupApplied() { |     backupApplied() { | ||||||
|       // Force refresh |       // Force refresh | ||||||
|       location.reload() |       location.reload() | ||||||
| @ -425,10 +419,6 @@ export default { | |||||||
|       this.socket.on('task_started', this.taskStarted) |       this.socket.on('task_started', this.taskStarted) | ||||||
|       this.socket.on('task_finished', this.taskFinished) |       this.socket.on('task_finished', this.taskFinished) | ||||||
| 
 | 
 | ||||||
|       // Feed Listeners |  | ||||||
|       this.socket.on('rss_feed_open', this.rssFeedOpen) |  | ||||||
|       this.socket.on('rss_feed_closed', this.rssFeedClosed) |  | ||||||
| 
 |  | ||||||
|       this.socket.on('backup_applied', this.backupApplied) |       this.socket.on('backup_applied', this.backupApplied) | ||||||
| 
 | 
 | ||||||
|       this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete) |       this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete) | ||||||
|  | |||||||
| @ -8,8 +8,8 @@ | |||||||
| <script> | <script> | ||||||
| export default { | export default { | ||||||
|   async asyncData({ store, params, redirect, query, app }) { |   async asyncData({ store, params, redirect, query, app }) { | ||||||
|     var libraryId = params.library |     const libraryId = params.library | ||||||
|     var libraryData = await store.dispatch('libraries/fetch', libraryId) |     const libraryData = await store.dispatch('libraries/fetch', libraryId) | ||||||
|     if (!libraryData) { |     if (!libraryData) { | ||||||
|       return redirect('/oops?message=Library not found') |       return redirect('/oops?message=Library not found') | ||||||
|     } |     } | ||||||
| @ -19,7 +19,7 @@ export default { | |||||||
|       return redirect(`/library/${libraryId}`) |       return redirect(`/library/${libraryId}`) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     var series = await app.$axios.$get(`/api/series/${params.id}?include=progress`).catch((error) => { |     const series = await app.$axios.$get(`/api/series/${params.id}?include=progress,rssfeed`).catch((error) => { | ||||||
|       console.error('Failed', error) |       console.error('Failed', error) | ||||||
|       return false |       return false | ||||||
|     }) |     }) | ||||||
|  | |||||||
| @ -124,6 +124,7 @@ class Server { | |||||||
| 
 | 
 | ||||||
|     await this.backupManager.init() |     await this.backupManager.init() | ||||||
|     await this.logManager.init() |     await this.logManager.init() | ||||||
|  |     await this.apiRouter.checkRemoveEmptySeries(this.db.series) // Remove empty series
 | ||||||
|     await this.rssFeedManager.init() |     await this.rssFeedManager.init() | ||||||
|     this.cronManager.init() |     this.cronManager.init() | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -375,6 +375,9 @@ class LibraryController { | |||||||
|   // api/libraries/:id/series
 |   // api/libraries/:id/series
 | ||||||
|   async getAllSeriesForLibrary(req, res) { |   async getAllSeriesForLibrary(req, res) { | ||||||
|     const libraryItems = req.libraryItems |     const libraryItems = req.libraryItems | ||||||
|  | 
 | ||||||
|  |     const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v) | ||||||
|  | 
 | ||||||
|     const payload = { |     const payload = { | ||||||
|       results: [], |       results: [], | ||||||
|       total: 0, |       total: 0, | ||||||
| @ -383,7 +386,8 @@ class LibraryController { | |||||||
|       sortBy: req.query.sort, |       sortBy: req.query.sort, | ||||||
|       sortDesc: req.query.desc === '1', |       sortDesc: req.query.desc === '1', | ||||||
|       filterBy: req.query.filter, |       filterBy: req.query.filter, | ||||||
|       minified: req.query.minified === '1' |       minified: req.query.minified === '1', | ||||||
|  |       include: include.join(',') | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, null, payload.filterBy, req.user, payload.minified) |     let series = libraryHelpers.getSeriesFromBooks(libraryItems, this.db.series, null, payload.filterBy, req.user, payload.minified) | ||||||
| @ -408,10 +412,19 @@ class LibraryController { | |||||||
|     payload.total = series.length |     payload.total = series.length | ||||||
| 
 | 
 | ||||||
|     if (payload.limit) { |     if (payload.limit) { | ||||||
|       var startIndex = payload.page * payload.limit |       const startIndex = payload.page * payload.limit | ||||||
|       series = series.slice(startIndex, startIndex + payload.limit) |       series = series.slice(startIndex, startIndex + payload.limit) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     // add rssFeed when "include=rssfeed" is in query string
 | ||||||
|  |     if (include.includes('rssfeed')) { | ||||||
|  |       series = series.map((se) => { | ||||||
|  |         const feedData = this.rssFeedManager.findFeedForEntityId(se.id) | ||||||
|  |         se.rssFeed = feedData?.toJSONMinified() || null | ||||||
|  |         return se | ||||||
|  |       }) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     payload.results = series |     payload.results = series | ||||||
|     res.json(payload) |     res.json(payload) | ||||||
|   } |   } | ||||||
| @ -442,7 +455,7 @@ class LibraryController { | |||||||
| 
 | 
 | ||||||
|       if (include.includes('rssfeed')) { |       if (include.includes('rssfeed')) { | ||||||
|         const feedData = this.rssFeedManager.findFeedForEntityId(c.id) |         const feedData = this.rssFeedManager.findFeedForEntityId(c.id) | ||||||
|         expanded.rssFeed = feedData ? feedData.toJSONMinified() : null |         expanded.rssFeed = feedData?.toJSONMinified() || null | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       return expanded |       return expanded | ||||||
|  | |||||||
| @ -92,10 +92,23 @@ class LibraryItemController { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     // Book specific - Get all series being removed from this item
 | ||||||
|  |     let seriesRemoved = [] | ||||||
|  |     if (libraryItem.isBook && mediaPayload.metadata?.series) { | ||||||
|  |       const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map(se => se.id) | ||||||
|  |       seriesRemoved = libraryItem.media.metadata.series.filter(se => !seriesIdsInUpdate.includes(se.id)) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     const hasUpdates = libraryItem.media.update(mediaPayload) |     const hasUpdates = libraryItem.media.update(mediaPayload) | ||||||
|     if (hasUpdates) { |     if (hasUpdates) { | ||||||
|       libraryItem.updatedAt = Date.now() |       libraryItem.updatedAt = Date.now() | ||||||
| 
 | 
 | ||||||
|  |       if (seriesRemoved.length) { | ||||||
|  |         // Check remove empty series
 | ||||||
|  |         Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`) | ||||||
|  |         await this.checkRemoveEmptySeries(seriesRemoved) | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       if (isPodcastAutoDownloadUpdated) { |       if (isPodcastAutoDownloadUpdated) { | ||||||
|         this.cronManager.checkUpdatePodcastCron(libraryItem) |         this.cronManager.checkUpdatePodcastCron(libraryItem) | ||||||
|       } |       } | ||||||
|  | |||||||
| @ -75,6 +75,41 @@ class RSSFeedController { | |||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   // POST: api/feeds/series/:seriesId/open
 | ||||||
|  |   async openRSSFeedForSeries(req, res) { | ||||||
|  |     const options = req.body || {} | ||||||
|  | 
 | ||||||
|  |     const series = this.db.series.find(se => se.id === req.params.seriesId) | ||||||
|  |     if (!series) return res.sendStatus(404) | ||||||
|  | 
 | ||||||
|  |     // Check request body options exist
 | ||||||
|  |     if (!options.serverAddress || !options.slug) { | ||||||
|  |       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 (this.rssFeedManager.feeds[options.slug]) { | ||||||
|  |       Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) | ||||||
|  |       return res.status(400).send('Slug already in use') | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const seriesJson = series.toJSON() | ||||||
|  |     // Get books in series that have audio tracks
 | ||||||
|  |     seriesJson.books = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) | ||||||
|  | 
 | ||||||
|  |     // Check series has audio tracks
 | ||||||
|  |     if (!seriesJson.books.length) { | ||||||
|  |       Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${seriesJson.name}" because it has no audio tracks`) | ||||||
|  |       return res.status(400).send('Series has no audio tracks') | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const feed = await this.rssFeedManager.openFeedForSeries(req.user, seriesJson, req.body) | ||||||
|  |     res.json({ | ||||||
|  |       feed: feed.toJSONMinified() | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   // POST: api/feeds/:id/close
 |   // POST: api/feeds/:id/close
 | ||||||
|   async closeRSSFeed(req, res) { |   async closeRSSFeed(req, res) { | ||||||
|     await this.rssFeedManager.closeRssFeed(req.params.id) |     await this.rssFeedManager.closeRssFeed(req.params.id) | ||||||
|  | |||||||
| @ -5,15 +5,15 @@ class SeriesController { | |||||||
|   constructor() { } |   constructor() { } | ||||||
| 
 | 
 | ||||||
|   async findOne(req, res) { |   async findOne(req, res) { | ||||||
|     var include = (req.query.include || '').split(',') |     const include = (req.query.include || '').split(',').map(v => v.trim()).filter(v => !!v) | ||||||
| 
 | 
 | ||||||
|     var seriesJson = req.series.toJSON() |     const seriesJson = req.series.toJSON() | ||||||
| 
 | 
 | ||||||
|     // Add progress map with isFinished flag
 |     // Add progress map with isFinished flag
 | ||||||
|     if (include.includes('progress')) { |     if (include.includes('progress')) { | ||||||
|       var libraryItemsInSeries = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(seriesJson.id)) |       const libraryItemsInSeries = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(seriesJson.id)) | ||||||
|       var libraryItemsFinished = libraryItemsInSeries.filter(li => { |       const libraryItemsFinished = libraryItemsInSeries.filter(li => { | ||||||
|         var mediaProgress = req.user.getMediaProgress(li.id) |         const mediaProgress = req.user.getMediaProgress(li.id) | ||||||
|         return mediaProgress && mediaProgress.isFinished |         return mediaProgress && mediaProgress.isFinished | ||||||
|       }) |       }) | ||||||
|       seriesJson.progress = { |       seriesJson.progress = { | ||||||
| @ -23,6 +23,11 @@ class SeriesController { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     if (include.includes('rssfeed')) { | ||||||
|  |       const feedObj = this.rssFeedManager.findFeedForEntityId(seriesJson.id) | ||||||
|  |       seriesJson.rssFeed = feedObj?.toJSONMinified() || null | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     return res.json(seriesJson) |     return res.json(seriesJson) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -47,7 +52,7 @@ class SeriesController { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   middleware(req, res, next) { |   middleware(req, res, next) { | ||||||
|     var series = this.db.series.find(se => se.id === req.params.id) |     const series = this.db.series.find(se => se.id === req.params.id) | ||||||
|     if (!series) return res.sendStatus(404) |     if (!series) return res.sendStatus(404) | ||||||
| 
 | 
 | ||||||
|     if (req.method == 'DELETE' && !req.user.canDelete) { |     if (req.method == 'DELETE' && !req.user.canDelete) { | ||||||
|  | |||||||
| @ -28,6 +28,13 @@ class RssFeedManager { | |||||||
|         Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`) |         Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`) | ||||||
|         return false |         return false | ||||||
|       } |       } | ||||||
|  |     } else if (feedObj.entityType === 'series') { | ||||||
|  |       const series = this.db.series.find(s => s.id === feedObj.entityId) | ||||||
|  |       const hasSeriesBook = this.db.libraryItems.some(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) | ||||||
|  |       if (!hasSeriesBook) { | ||||||
|  |         Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found or has no audio tracks`) | ||||||
|  |         return false | ||||||
|  |       } | ||||||
|     } else { |     } else { | ||||||
|       Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Invalid entityType "${feedObj.entityType}"`) |       Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Invalid entityType "${feedObj.entityType}"`) | ||||||
|       return false |       return false | ||||||
| @ -73,6 +80,7 @@ class RssFeedManager { | |||||||
|       return |       return | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     // Check if feed needs to be updated
 | ||||||
|     if (feed.entityType === 'item') { |     if (feed.entityType === 'item') { | ||||||
|       const libraryItem = this.db.getLibraryItem(feed.entityId) |       const libraryItem = this.db.getLibraryItem(feed.entityId) | ||||||
|       if (libraryItem && (!feed.entityUpdatedAt || libraryItem.updatedAt > feed.entityUpdatedAt)) { |       if (libraryItem && (!feed.entityUpdatedAt || libraryItem.updatedAt > feed.entityUpdatedAt)) { | ||||||
| @ -100,6 +108,33 @@ class RssFeedManager { | |||||||
|           await this.db.updateEntity('feed', feed) |           await this.db.updateEntity('feed', feed) | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  |     } else if (feed.entityType === 'series') { | ||||||
|  |       const series = this.db.series.find(s => s.id === feed.entityId) | ||||||
|  |       if (series) { | ||||||
|  |         const seriesJson = series.toJSON() | ||||||
|  |         // Get books in series that have audio tracks
 | ||||||
|  |         seriesJson.books = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(series.id) && li.media.tracks.length) | ||||||
|  | 
 | ||||||
|  |         // Find most recently updated item in series
 | ||||||
|  |         let mostRecentlyUpdatedAt = seriesJson.updatedAt | ||||||
|  |         let totalTracks = 0 // Used to detect series items removed
 | ||||||
|  |         seriesJson.books.forEach((libraryItem) => { | ||||||
|  |           totalTracks += libraryItem.media.tracks.length | ||||||
|  |           if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) { | ||||||
|  |             mostRecentlyUpdatedAt = libraryItem.updatedAt | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |         if (totalTracks !== feed.episodes.length) { | ||||||
|  |           mostRecentlyUpdatedAt = Date.now() | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         if (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt) { | ||||||
|  |           Logger.debug(`[RssFeedManager] Updating RSS feed for series "${seriesJson.name}"`) | ||||||
|  | 
 | ||||||
|  |           feed.updateFromSeries(seriesJson) | ||||||
|  |           await this.db.updateEntity('feed', feed) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const xml = feed.buildXml() |     const xml = feed.buildXml() | ||||||
| @ -170,6 +205,20 @@ class RssFeedManager { | |||||||
|     return feed |     return feed | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async openFeedForSeries(user, seriesExpanded, options) { | ||||||
|  |     const serverAddress = options.serverAddress | ||||||
|  |     const slug = options.slug | ||||||
|  | 
 | ||||||
|  |     const feed = new Feed() | ||||||
|  |     feed.setFromSeries(user.id, slug, seriesExpanded, serverAddress) | ||||||
|  |     this.feeds[feed.id] = feed | ||||||
|  | 
 | ||||||
|  |     Logger.debug(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) | ||||||
|  |     await this.db.insertEntity('feed', feed) | ||||||
|  |     SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) | ||||||
|  |     return feed | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   async handleCloseFeed(feed) { |   async handleCloseFeed(feed) { | ||||||
|     if (!feed) return |     if (!feed) return | ||||||
|     await this.db.removeEntity('feed', feed.id) |     await this.db.removeEntity('feed', feed.id) | ||||||
|  | |||||||
| @ -1,6 +1,10 @@ | |||||||
| const FeedMeta = require('./FeedMeta') | const FeedMeta = require('./FeedMeta') | ||||||
| const FeedEpisode = require('./FeedEpisode') | const FeedEpisode = require('./FeedEpisode') | ||||||
| const RSS = require('../libs/rss') | const RSS = require('../libs/rss') | ||||||
|  | const { createNewSortInstance } = require('../libs/fastSort') | ||||||
|  | const naturalSort = createNewSortInstance({ | ||||||
|  |   comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare | ||||||
|  | }) | ||||||
| 
 | 
 | ||||||
| class Feed { | class Feed { | ||||||
|   constructor(feed) { |   constructor(feed) { | ||||||
| @ -226,6 +230,83 @@ class Feed { | |||||||
|     this.xml = null |     this.xml = null | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   setFromSeries(userId, slug, seriesExpanded, serverAddress) { | ||||||
|  |     const feedUrl = `${serverAddress}/feed/${slug}` | ||||||
|  | 
 | ||||||
|  |     let itemsWithTracks = seriesExpanded.books.filter(libraryItem => libraryItem.media.tracks.length) | ||||||
|  |     // Sort series items by series sequence
 | ||||||
|  |     itemsWithTracks = naturalSort(itemsWithTracks).asc(li => li.media.metadata.getSeriesSequence(seriesExpanded.id)) | ||||||
|  | 
 | ||||||
|  |     const libraryId = itemsWithTracks[0].libraryId | ||||||
|  |     const firstItemWithCover = itemsWithTracks.find(li => li.media.coverPath) | ||||||
|  | 
 | ||||||
|  |     this.id = slug | ||||||
|  |     this.slug = slug | ||||||
|  |     this.userId = userId | ||||||
|  |     this.entityType = 'series' | ||||||
|  |     this.entityId = seriesExpanded.id | ||||||
|  |     this.entityUpdatedAt = seriesExpanded.updatedAt // This will be set to the most recently updated library item
 | ||||||
|  |     this.coverPath = firstItemWithCover?.coverPath || null | ||||||
|  |     this.serverAddress = serverAddress | ||||||
|  |     this.feedUrl = feedUrl | ||||||
|  | 
 | ||||||
|  |     this.meta = new FeedMeta() | ||||||
|  |     this.meta.title = seriesExpanded.name | ||||||
|  |     this.meta.description = seriesExpanded.description || '' | ||||||
|  |     this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) | ||||||
|  |     this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png` | ||||||
|  |     this.meta.feedUrl = feedUrl | ||||||
|  |     this.meta.link = `${serverAddress}/library/${libraryId}/series/${seriesExpanded.id}` | ||||||
|  |     this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit
 | ||||||
|  | 
 | ||||||
|  |     this.episodes = [] | ||||||
|  | 
 | ||||||
|  |     itemsWithTracks.forEach((item, index) => { | ||||||
|  |       if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt | ||||||
|  | 
 | ||||||
|  |       item.media.tracks.forEach((audioTrack) => { | ||||||
|  |         const feedEpisode = new FeedEpisode() | ||||||
|  |         feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, index) | ||||||
|  |         this.episodes.push(feedEpisode) | ||||||
|  |       }) | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     this.createdAt = Date.now() | ||||||
|  |     this.updatedAt = Date.now() | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   updateFromSeries(seriesExpanded) { | ||||||
|  |     let itemsWithTracks = seriesExpanded.books.filter(libraryItem => libraryItem.media.tracks.length) | ||||||
|  |     // Sort series items by series sequence
 | ||||||
|  |     itemsWithTracks = naturalSort(itemsWithTracks).asc(li => li.media.metadata.getSeriesSequence(seriesExpanded.id)) | ||||||
|  | 
 | ||||||
|  |     const firstItemWithCover = itemsWithTracks.find(item => item.media.coverPath) | ||||||
|  | 
 | ||||||
|  |     this.entityUpdatedAt = seriesExpanded.updatedAt | ||||||
|  |     this.coverPath = firstItemWithCover?.coverPath || null | ||||||
|  | 
 | ||||||
|  |     this.meta.title = seriesExpanded.name | ||||||
|  |     this.meta.description = seriesExpanded.description || '' | ||||||
|  |     this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) | ||||||
|  |     this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png` | ||||||
|  |     this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit
 | ||||||
|  | 
 | ||||||
|  |     this.episodes = [] | ||||||
|  | 
 | ||||||
|  |     itemsWithTracks.forEach((item, index) => { | ||||||
|  |       if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt | ||||||
|  | 
 | ||||||
|  |       item.media.tracks.forEach((audioTrack) => { | ||||||
|  |         const feedEpisode = new FeedEpisode() | ||||||
|  |         feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, index) | ||||||
|  |         this.episodes.push(feedEpisode) | ||||||
|  |       }) | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     this.updatedAt = Date.now() | ||||||
|  |     this.xml = null | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   buildXml() { |   buildXml() { | ||||||
|     if (this.xml) return this.xml |     if (this.xml) return this.xml | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -175,7 +175,7 @@ class BookMetadata { | |||||||
|     return this.series.length ? this.series[0] : null |     return this.series.length ? this.series[0] : null | ||||||
|   } |   } | ||||||
|   getSeriesSequence(seriesId) { |   getSeriesSequence(seriesId) { | ||||||
|     var series = this.series.find(se => se.id == seriesId) |     const series = this.series.find(se => se.id == seriesId) | ||||||
|     if (!series) return null |     if (!series) return null | ||||||
|     return series.sequence || '' |     return series.sequence || '' | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -268,6 +268,7 @@ class ApiRouter { | |||||||
|     //
 |     //
 | ||||||
|     this.router.post('/feeds/item/:itemId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForItem.bind(this)) |     this.router.post('/feeds/item/:itemId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForItem.bind(this)) | ||||||
|     this.router.post('/feeds/collection/:collectionId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForCollection.bind(this)) |     this.router.post('/feeds/collection/:collectionId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForCollection.bind(this)) | ||||||
|  |     this.router.post('/feeds/series/:seriesId/open', RSSFeedController.middleware.bind(this), RSSFeedController.openRSSFeedForSeries.bind(this)) | ||||||
|     this.router.post('/feeds/:id/close', RSSFeedController.middleware.bind(this), RSSFeedController.closeRSSFeed.bind(this)) |     this.router.post('/feeds/:id/close', RSSFeedController.middleware.bind(this), RSSFeedController.closeRSSFeed.bind(this)) | ||||||
| 
 | 
 | ||||||
|     //
 |     //
 | ||||||
| @ -360,13 +361,18 @@ class ApiRouter { | |||||||
| 
 | 
 | ||||||
|     // TODO: Remove open sessions for library item
 |     // TODO: Remove open sessions for library item
 | ||||||
| 
 | 
 | ||||||
|     // remove book from collections
 |     if (libraryItem.isBook) { | ||||||
|     const collectionsWithBook = this.db.collections.filter(c => c.books.includes(libraryItem.id)) |       // remove book from collections
 | ||||||
|     for (let i = 0; i < collectionsWithBook.length; i++) { |       const collectionsWithBook = this.db.collections.filter(c => c.books.includes(libraryItem.id)) | ||||||
|       const collection = collectionsWithBook[i] |       for (let i = 0; i < collectionsWithBook.length; i++) { | ||||||
|       collection.removeBook(libraryItem.id) |         const collection = collectionsWithBook[i] | ||||||
|       await this.db.updateEntity('collection', collection) |         collection.removeBook(libraryItem.id) | ||||||
|       SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems)) |         await this.db.updateEntity('collection', collection) | ||||||
|  |         SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems)) | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       // Check remove empty series
 | ||||||
|  |       await this.checkRemoveEmptySeries(libraryItem.media.metadata.series, libraryItem.id) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // remove item from playlists
 |     // remove item from playlists
 | ||||||
| @ -398,6 +404,21 @@ class ApiRouter { | |||||||
|     SocketAuthority.emitter('item_removed', libraryItem.toJSONExpanded()) |     SocketAuthority.emitter('item_removed', libraryItem.toJSONExpanded()) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async checkRemoveEmptySeries(seriesToCheck, excludeLibraryItemId = null) { | ||||||
|  |     if (!seriesToCheck || !seriesToCheck.length) return | ||||||
|  | 
 | ||||||
|  |     for (const series of seriesToCheck) { | ||||||
|  |       const otherLibraryItemsInSeries = this.db.libraryItems.filter(li => li.id !== excludeLibraryItemId && li.isBook && li.media.metadata.hasSeries(series.id)) | ||||||
|  |       if (!otherLibraryItemsInSeries.length) { | ||||||
|  |         // Close open RSS feed for series
 | ||||||
|  |         await this.rssFeedManager.closeFeedForEntityId(series.id) | ||||||
|  |         Logger.debug(`[ApiRouter] Series "${series.name}" is now empty. Removing series`) | ||||||
|  |         await this.db.removeEntity('series', series.id) | ||||||
|  |         // TODO: Socket events for series?
 | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   async getUserListeningSessionsHelper(userId) { |   async getUserListeningSessionsHelper(userId) { | ||||||
|     const userSessions = await this.db.selectUserSessions(userId) |     const userSessions = await this.db.selectUserSessions(userId) | ||||||
|     return userSessions.sort((a, b) => b.updatedAt - a.updatedAt) |     return userSessions.sort((a, b) => b.updatedAt - a.updatedAt) | ||||||
|  | |||||||
| @ -205,7 +205,7 @@ module.exports = { | |||||||
|       }) |       }) | ||||||
|     }) |     }) | ||||||
| 
 | 
 | ||||||
|     var seriesItems = Object.values(_series) |     let seriesItems = Object.values(_series) | ||||||
| 
 | 
 | ||||||
|     // check progress filter
 |     // check progress filter
 | ||||||
|     if (filterBy && filterBy.startsWith('progress.') && user) { |     if (filterBy && filterBy.startsWith('progress.') && user) { | ||||||
| @ -691,6 +691,11 @@ module.exports = { | |||||||
|             item.rssFeed = ctx.rssFeedManager.findFeedForEntityId(item.id)?.toJSONMinified() || null |             item.rssFeed = ctx.rssFeedManager.findFeedForEntityId(item.id)?.toJSONMinified() || null | ||||||
|             return item |             return item | ||||||
|           }) |           }) | ||||||
|  |         } else if (shelf.type === 'series') { | ||||||
|  |           shelf.entities = shelf.entities.map((series) => { | ||||||
|  |             series.rssFeed = ctx.rssFeedManager.findFeedForEntityId(series.id)?.toJSONMinified() || null | ||||||
|  |             return series | ||||||
|  |           }) | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user