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" /> | ||||
|         <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" /> | ||||
|       </template> | ||||
|       <!-- library & collections page --> | ||||
| @ -229,6 +234,9 @@ export default { | ||||
|     seriesProgress() { | ||||
|       return this.selectedSeries ? this.selectedSeries.progress : null | ||||
|     }, | ||||
|     seriesRssFeed() { | ||||
|       return this.selectedSeries ? this.selectedSeries.rssFeed : null | ||||
|     }, | ||||
|     seriesLibraryItemIds() { | ||||
|       if (!this.seriesProgress) return [] | ||||
|       return this.seriesProgress.libraryItemIds || [] | ||||
| @ -253,7 +261,7 @@ export default { | ||||
|   methods: { | ||||
|     seriesContextMenuAction(action) { | ||||
|       if (action === 'open-rss-feed') { | ||||
|         // TODO: Open RSS Feed | ||||
|         this.showOpenSeriesRSSFeed() | ||||
|       } else if (action === 're-add-to-continue-listening') { | ||||
|         if (this.processingSeries) { | ||||
|           console.warn('Already processing series') | ||||
| @ -268,6 +276,14 @@ export default { | ||||
|         this.markSeriesFinished() | ||||
|       } | ||||
|     }, | ||||
|     showOpenSeriesRSSFeed() { | ||||
|       this.$store.commit('globals/setRSSFeedOpenCloseModal', { | ||||
|         id: this.selectedSeries.id, | ||||
|         name: this.selectedSeries.name, | ||||
|         type: 'series', | ||||
|         feed: this.selectedSeries.rssFeed | ||||
|       }) | ||||
|     }, | ||||
|     reAddSeriesToContinueListening() { | ||||
|       this.processingSeries = true | ||||
|       this.$axios | ||||
| @ -396,16 +412,32 @@ export default { | ||||
|     }, | ||||
|     setBookshelfTotalEntities(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() { | ||||
|     this.init() | ||||
|     this.$eventBus.$on('user-settings', this.settingsUpdated) | ||||
|     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() { | ||||
|     this.$eventBus.$off('user-settings', this.settingsUpdated) | ||||
|     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> | ||||
|  | ||||
| @ -13,6 +13,8 @@ | ||||
|       <p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p> | ||||
|     </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 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> | ||||
| @ -125,6 +127,9 @@ export default { | ||||
|     isAlternativeBookshelfView() { | ||||
|       const constants = this.$constants || this.$nuxt.$constants | ||||
|       return this.bookshelfView == constants.BookshelfView.DETAIL | ||||
|     }, | ||||
|     rssFeed() { | ||||
|       return this.series ? this.series.rssFeed : null | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|  | ||||
| @ -330,12 +330,6 @@ export default { | ||||
|       } | ||||
|       this.$store.commit('libraries/removeUserPlaylist', playlist) | ||||
|     }, | ||||
|     rssFeedOpen(data) { | ||||
|       console.log('RSS Feed Open', data) | ||||
|     }, | ||||
|     rssFeedClosed(data) { | ||||
|       console.log('RSS Feed Closed', data) | ||||
|     }, | ||||
|     backupApplied() { | ||||
|       // Force refresh | ||||
|       location.reload() | ||||
| @ -425,10 +419,6 @@ export default { | ||||
|       this.socket.on('task_started', this.taskStarted) | ||||
|       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('batch_quickmatch_complete', this.batchQuickMatchComplete) | ||||
|  | ||||
| @ -8,8 +8,8 @@ | ||||
| <script> | ||||
| export default { | ||||
|   async asyncData({ store, params, redirect, query, app }) { | ||||
|     var libraryId = params.library | ||||
|     var libraryData = await store.dispatch('libraries/fetch', libraryId) | ||||
|     const libraryId = params.library | ||||
|     const libraryData = await store.dispatch('libraries/fetch', libraryId) | ||||
|     if (!libraryData) { | ||||
|       return redirect('/oops?message=Library not found') | ||||
|     } | ||||
| @ -19,7 +19,7 @@ export default { | ||||
|       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) | ||||
|       return false | ||||
|     }) | ||||
|  | ||||
| @ -124,6 +124,7 @@ class Server { | ||||
| 
 | ||||
|     await this.backupManager.init() | ||||
|     await this.logManager.init() | ||||
|     await this.apiRouter.checkRemoveEmptySeries(this.db.series) // Remove empty series
 | ||||
|     await this.rssFeedManager.init() | ||||
|     this.cronManager.init() | ||||
| 
 | ||||
|  | ||||
| @ -375,6 +375,9 @@ class LibraryController { | ||||
|   // api/libraries/:id/series
 | ||||
|   async getAllSeriesForLibrary(req, res) { | ||||
|     const libraryItems = req.libraryItems | ||||
| 
 | ||||
|     const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v) | ||||
| 
 | ||||
|     const payload = { | ||||
|       results: [], | ||||
|       total: 0, | ||||
| @ -383,7 +386,8 @@ class LibraryController { | ||||
|       sortBy: req.query.sort, | ||||
|       sortDesc: req.query.desc === '1', | ||||
|       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) | ||||
| @ -408,10 +412,19 @@ class LibraryController { | ||||
|     payload.total = series.length | ||||
| 
 | ||||
|     if (payload.limit) { | ||||
|       var startIndex = payload.page * payload.limit | ||||
|       const startIndex = payload.page * 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 | ||||
|     res.json(payload) | ||||
|   } | ||||
| @ -442,7 +455,7 @@ class LibraryController { | ||||
| 
 | ||||
|       if (include.includes('rssfeed')) { | ||||
|         const feedData = this.rssFeedManager.findFeedForEntityId(c.id) | ||||
|         expanded.rssFeed = feedData ? feedData.toJSONMinified() : null | ||||
|         expanded.rssFeed = feedData?.toJSONMinified() || null | ||||
|       } | ||||
| 
 | ||||
|       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) | ||||
|     if (hasUpdates) { | ||||
|       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) { | ||||
|         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
 | ||||
|   async closeRSSFeed(req, res) { | ||||
|     await this.rssFeedManager.closeRssFeed(req.params.id) | ||||
|  | ||||
| @ -5,15 +5,15 @@ class SeriesController { | ||||
|   constructor() { } | ||||
| 
 | ||||
|   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
 | ||||
|     if (include.includes('progress')) { | ||||
|       var libraryItemsInSeries = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(seriesJson.id)) | ||||
|       var libraryItemsFinished = libraryItemsInSeries.filter(li => { | ||||
|         var mediaProgress = req.user.getMediaProgress(li.id) | ||||
|       const libraryItemsInSeries = this.db.libraryItems.filter(li => li.mediaType === 'book' && li.media.metadata.hasSeries(seriesJson.id)) | ||||
|       const libraryItemsFinished = libraryItemsInSeries.filter(li => { | ||||
|         const mediaProgress = req.user.getMediaProgress(li.id) | ||||
|         return mediaProgress && mediaProgress.isFinished | ||||
|       }) | ||||
|       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) | ||||
|   } | ||||
| 
 | ||||
| @ -47,7 +52,7 @@ class SeriesController { | ||||
|   } | ||||
| 
 | ||||
|   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 (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`) | ||||
|         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 { | ||||
|       Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Invalid entityType "${feedObj.entityType}"`) | ||||
|       return false | ||||
| @ -73,6 +80,7 @@ class RssFeedManager { | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     // Check if feed needs to be updated
 | ||||
|     if (feed.entityType === 'item') { | ||||
|       const libraryItem = this.db.getLibraryItem(feed.entityId) | ||||
|       if (libraryItem && (!feed.entityUpdatedAt || libraryItem.updatedAt > feed.entityUpdatedAt)) { | ||||
| @ -100,6 +108,33 @@ class RssFeedManager { | ||||
|           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() | ||||
| @ -170,6 +205,20 @@ class RssFeedManager { | ||||
|     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) { | ||||
|     if (!feed) return | ||||
|     await this.db.removeEntity('feed', feed.id) | ||||
|  | ||||
| @ -1,6 +1,10 @@ | ||||
| const FeedMeta = require('./FeedMeta') | ||||
| const FeedEpisode = require('./FeedEpisode') | ||||
| 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 { | ||||
|   constructor(feed) { | ||||
| @ -226,6 +230,83 @@ class Feed { | ||||
|     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() { | ||||
|     if (this.xml) return this.xml | ||||
| 
 | ||||
|  | ||||
| @ -175,7 +175,7 @@ class BookMetadata { | ||||
|     return this.series.length ? this.series[0] : null | ||||
|   } | ||||
|   getSeriesSequence(seriesId) { | ||||
|     var series = this.series.find(se => se.id == seriesId) | ||||
|     const series = this.series.find(se => se.id == seriesId) | ||||
|     if (!series) return null | ||||
|     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/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)) | ||||
| 
 | ||||
|     //
 | ||||
| @ -360,13 +361,18 @@ class ApiRouter { | ||||
| 
 | ||||
|     // TODO: Remove open sessions for library item
 | ||||
| 
 | ||||
|     // remove book from collections
 | ||||
|     const collectionsWithBook = this.db.collections.filter(c => c.books.includes(libraryItem.id)) | ||||
|     for (let i = 0; i < collectionsWithBook.length; i++) { | ||||
|       const collection = collectionsWithBook[i] | ||||
|       collection.removeBook(libraryItem.id) | ||||
|       await this.db.updateEntity('collection', collection) | ||||
|       SocketAuthority.emitter('collection_updated', collection.toJSONExpanded(this.db.libraryItems)) | ||||
|     if (libraryItem.isBook) { | ||||
|       // remove book from collections
 | ||||
|       const collectionsWithBook = this.db.collections.filter(c => c.books.includes(libraryItem.id)) | ||||
|       for (let i = 0; i < collectionsWithBook.length; i++) { | ||||
|         const collection = collectionsWithBook[i] | ||||
|         collection.removeBook(libraryItem.id) | ||||
|         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
 | ||||
| @ -398,6 +404,21 @@ class ApiRouter { | ||||
|     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) { | ||||
|     const userSessions = await this.db.selectUserSessions(userId) | ||||
|     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
 | ||||
|     if (filterBy && filterBy.startsWith('progress.') && user) { | ||||
| @ -691,6 +691,11 @@ module.exports = { | ||||
|             item.rssFeed = ctx.rssFeedManager.findFeedForEntityId(item.id)?.toJSONMinified() || null | ||||
|             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