Add:RSS feed for series & cleanup empty series from db #1265

This commit is contained in:
advplyr 2022-12-31 16:58:19 -06:00
parent a364fe5031
commit 70ba2f7850
14 changed files with 282 additions and 32 deletions

View File

@ -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>

View File

@ -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: {

View File

@ -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)

View File

@ -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
})

View File

@ -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()

View File

@ -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

View File

@ -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)
}

View File

@ -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)

View File

@ -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) {

View File

@ -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)

View File

@ -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

View File

@ -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 || ''
}

View File

@ -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)

View File

@ -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
})
}
}