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

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