Merge pull request #3724 from advplyr/feed_migration

Refactor Feed model to create new feed for collection
This commit is contained in:
advplyr 2024-12-15 17:59:17 -06:00 committed by GitHub
commit 6cef1e3f12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 872 additions and 1174 deletions

View File

@ -444,21 +444,6 @@ class Database {
return updated return updated
} }
async createFeed(oldFeed) {
if (!this.sequelize) return false
await this.models.feed.fullCreateFromOld(oldFeed)
}
updateFeed(oldFeed) {
if (!this.sequelize) return false
return this.models.feed.fullUpdateFromOld(oldFeed)
}
async removeFeed(feedId) {
if (!this.sequelize) return false
await this.models.feed.removeById(feedId)
}
async createBulkBookAuthors(bookAuthors) { async createBulkBookAuthors(bookAuthors) {
if (!this.sequelize) return false if (!this.sequelize) return false
await this.models.bookAuthor.bulkCreate(bookAuthors) await this.models.bookAuthor.bulkCreate(bookAuthors)

View File

@ -71,7 +71,6 @@ class Server {
this.playbackSessionManager = new PlaybackSessionManager() this.playbackSessionManager = new PlaybackSessionManager()
this.podcastManager = new PodcastManager() this.podcastManager = new PodcastManager()
this.audioMetadataManager = new AudioMetadataMangaer() this.audioMetadataManager = new AudioMetadataMangaer()
this.rssFeedManager = new RssFeedManager()
this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager) this.cronManager = new CronManager(this.podcastManager, this.playbackSessionManager)
this.apiCacheManager = new ApiCacheManager() this.apiCacheManager = new ApiCacheManager()
this.binaryManager = new BinaryManager() this.binaryManager = new BinaryManager()
@ -137,7 +136,7 @@ class Server {
await ShareManager.init() await ShareManager.init()
await this.backupManager.init() await this.backupManager.init()
await this.rssFeedManager.init() await RssFeedManager.init()
const libraries = await Database.libraryModel.getAllWithFolders() const libraries = await Database.libraryModel.getAllWithFolders()
await this.cronManager.init(libraries) await this.cronManager.init(libraries)
@ -291,14 +290,14 @@ class Server {
// RSS Feed temp route // RSS Feed temp route
router.get('/feed/:slug', (req, res) => { router.get('/feed/:slug', (req, res) => {
Logger.info(`[Server] Requesting rss feed ${req.params.slug}`) Logger.info(`[Server] Requesting rss feed ${req.params.slug}`)
this.rssFeedManager.getFeed(req, res) RssFeedManager.getFeed(req, res)
}) })
router.get('/feed/:slug/cover*', (req, res) => { router.get('/feed/:slug/cover*', (req, res) => {
this.rssFeedManager.getFeedCover(req, res) RssFeedManager.getFeedCover(req, res)
}) })
router.get('/feed/:slug/item/:episodeId/*', (req, res) => { router.get('/feed/:slug/item/:episodeId/*', (req, res) => {
Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`) Logger.debug(`[Server] Requesting rss feed episode ${req.params.slug}/${req.params.episodeId}`)
this.rssFeedManager.getFeedItem(req, res) RssFeedManager.getFeedItem(req, res)
}) })
// Auth routes // Auth routes

View File

@ -4,6 +4,7 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const RssFeedManager = require('../managers/RssFeedManager')
const Collection = require('../objects/Collection') const Collection = require('../objects/Collection')
/** /**
@ -115,6 +116,7 @@ class CollectionController {
} }
// If books array is passed in then update order in collection // If books array is passed in then update order in collection
let collectionBooksUpdated = false
if (req.body.books?.length) { if (req.body.books?.length) {
const collectionBooks = await req.collection.getCollectionBooks({ const collectionBooks = await req.collection.getCollectionBooks({
include: { include: {
@ -133,9 +135,15 @@ class CollectionController {
await collectionBooks[i].update({ await collectionBooks[i].update({
order: i + 1 order: i + 1
}) })
wasUpdated = true collectionBooksUpdated = true
} }
} }
if (collectionBooksUpdated) {
req.collection.changed('updatedAt', true)
await req.collection.save()
wasUpdated = true
}
} }
const jsonExpanded = await req.collection.getOldJsonExpanded() const jsonExpanded = await req.collection.getOldJsonExpanded()
@ -148,6 +156,8 @@ class CollectionController {
/** /**
* DELETE: /api/collections/:id * DELETE: /api/collections/:id
* *
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
@ -155,7 +165,7 @@ class CollectionController {
const jsonExpanded = await req.collection.getOldJsonExpanded() const jsonExpanded = await req.collection.getOldJsonExpanded()
// Close rss feed - remove from db and emit socket event // Close rss feed - remove from db and emit socket event
await this.rssFeedManager.closeFeedForEntityId(req.collection.id) await RssFeedManager.closeFeedForEntityId(req.collection.id)
await req.collection.destroy() await req.collection.destroy()

View File

@ -18,6 +18,8 @@ const LibraryScanner = require('../scanner/LibraryScanner')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
const Database = require('../Database') const Database = require('../Database')
const Watcher = require('../Watcher') const Watcher = require('../Watcher')
const RssFeedManager = require('../managers/RssFeedManager')
const libraryFilters = require('../utils/queries/libraryFilters') const libraryFilters = require('../utils/queries/libraryFilters')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
const authorFilters = require('../utils/queries/authorFilters') const authorFilters = require('../utils/queries/authorFilters')
@ -759,8 +761,8 @@ class LibraryController {
} }
if (include.includes('rssfeed')) { if (include.includes('rssfeed')) {
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id) const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
} }
res.json(seriesJson) res.json(seriesJson)

View File

@ -13,6 +13,8 @@ const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUti
const LibraryItemScanner = require('../scanner/LibraryItemScanner') const LibraryItemScanner = require('../scanner/LibraryItemScanner')
const AudioFileScanner = require('../scanner/AudioFileScanner') const AudioFileScanner = require('../scanner/AudioFileScanner')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
const RssFeedManager = require('../managers/RssFeedManager')
const CacheManager = require('../managers/CacheManager') const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager') const CoverManager = require('../managers/CoverManager')
const ShareManager = require('../managers/ShareManager') const ShareManager = require('../managers/ShareManager')
@ -48,8 +50,8 @@ class LibraryItemController {
} }
if (includeEntities.includes('rssfeed')) { if (includeEntities.includes('rssfeed')) {
const feedData = await this.rssFeedManager.findFeedForEntityId(item.id) const feedData = await RssFeedManager.findFeedForEntityId(item.id)
item.rssFeed = feedData?.toJSONMinified() || null item.rssFeed = feedData?.toOldJSONMinified() || null
} }
if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) { if (item.mediaType === 'book' && req.user.isAdminOrUp && includeEntities.includes('share')) {

View File

@ -1,7 +1,8 @@
const { Request, Response, NextFunction } = require('express') const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger') const Logger = require('../Logger')
const Database = require('../Database') const Database = require('../Database')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
const RssFeedManager = require('../managers/RssFeedManager')
/** /**
* @typedef RequestUserObject * @typedef RequestUserObject
@ -22,10 +23,10 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async getAll(req, res) { async getAll(req, res) {
const feeds = await this.rssFeedManager.getFeeds() const feeds = await RssFeedManager.getFeeds()
res.json({ res.json({
feeds: feeds.map((f) => f.toJSON()), feeds: feeds.map((f) => f.toOldJSON()),
minified: feeds.map((f) => f.toJSONMinified()) minified: feeds.map((f) => f.toOldJSONMinified())
}) })
} }
@ -62,12 +63,12 @@ class RSSFeedController {
} }
// Check that this slug is not being used for another feed (slug will also be the Feed id) // Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(reqBody.slug)) { if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`) Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const feed = await this.rssFeedManager.openFeedForItem(req.user.id, itemExpanded, reqBody) const feed = await RssFeedManager.openFeedForItem(req.user.id, itemExpanded, reqBody)
if (!feed) { if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for item "${itemExpanded.media.title}"`) Logger.error(`[RSSFeedController] Failed to open RSS feed for item "${itemExpanded.media.title}"`)
return res.status(500).send('Failed to open RSS feed') return res.status(500).send('Failed to open RSS feed')
@ -87,35 +88,37 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async openRSSFeedForCollection(req, res) { async openRSSFeedForCollection(req, res) {
const options = req.body || {} const reqBody = req.body || {}
const collection = await Database.collectionModel.findByPk(req.params.collectionId)
if (!collection) return res.sendStatus(404)
// Check request body options exist // Check request body options exist
if (!options.serverAddress || !options.slug) { if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body') 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) // Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) { if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const collectionExpanded = await collection.getOldJsonExpanded() const collection = await Database.collectionModel.getExpandedById(req.params.collectionId)
const collectionItemsWithTracks = collectionExpanded.books.filter((li) => li.media.tracks.length) if (!collection) return res.sendStatus(404)
// Check collection has audio tracks // Check collection has audio tracks
if (!collectionItemsWithTracks.length) { if (!collection.books.some((book) => book.includedAudioFiles.length)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`) Logger.error(`[RSSFeedController] Cannot open RSS feed for collection "${collection.name}" because it has no audio tracks`)
return res.status(400).send('Collection has no audio tracks') return res.status(400).send('Collection has no audio tracks')
} }
const feed = await this.rssFeedManager.openFeedForCollection(req.user.id, collectionExpanded, req.body) const feed = await RssFeedManager.openFeedForCollection(req.user.id, collection, reqBody)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for collection "${collection.name}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({ res.json({
feed: feed.toJSONMinified() feed: feed.toOldJSONMinified()
}) })
} }
@ -128,37 +131,37 @@ class RSSFeedController {
* @param {Response} res * @param {Response} res
*/ */
async openRSSFeedForSeries(req, res) { async openRSSFeedForSeries(req, res) {
const options = req.body || {} const reqBody = req.body || {}
const series = await Database.seriesModel.findByPk(req.params.seriesId)
if (!series) return res.sendStatus(404)
// Check request body options exist // Check request body options exist
if (!options.serverAddress || !options.slug) { if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`)
return res.status(400).send('Invalid request body') 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) // Check that this slug is not being used for another feed (slug will also be the Feed id)
if (await this.rssFeedManager.findFeedBySlug(options.slug)) { if (await RssFeedManager.checkExistsBySlug(reqBody.slug)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${options.slug}" is already in use`) Logger.error(`[RSSFeedController] Cannot open RSS feed because slug "${reqBody.slug}" is already in use`)
return res.status(400).send('Slug already in use') return res.status(400).send('Slug already in use')
} }
const seriesJson = series.toOldJSON() const series = await Database.seriesModel.getExpandedById(req.params.seriesId)
if (!series) return res.sendStatus(404)
// Get books in series that have audio tracks
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
// Check series has audio tracks // Check series has audio tracks
if (!seriesJson.books.length) { if (!series.books.some((book) => book.includedAudioFiles.length)) {
Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${seriesJson.name}" because it has no audio tracks`) Logger.error(`[RSSFeedController] Cannot open RSS feed for series "${series.name}" because it has no audio tracks`)
return res.status(400).send('Series has no audio tracks') return res.status(400).send('Series has no audio tracks')
} }
const feed = await this.rssFeedManager.openFeedForSeries(req.user.id, seriesJson, req.body) const feed = await RssFeedManager.openFeedForSeries(req.user.id, series, req.body)
if (!feed) {
Logger.error(`[RSSFeedController] Failed to open RSS feed for series "${series.name}"`)
return res.status(500).send('Failed to open RSS feed')
}
res.json({ res.json({
feed: feed.toJSONMinified() feed: feed.toOldJSONMinified()
}) })
} }
@ -170,8 +173,16 @@ class RSSFeedController {
* @param {RequestWithUser} req * @param {RequestWithUser} req
* @param {Response} res * @param {Response} res
*/ */
closeRSSFeed(req, res) { async closeRSSFeed(req, res) {
this.rssFeedManager.closeRssFeed(req, res) const feed = await Database.feedModel.findByPk(req.params.id)
if (!feed) {
Logger.error(`[RSSFeedController] Cannot close RSS feed because feed "${req.params.id}" does not exist`)
return res.sendStatus(404)
}
await RssFeedManager.handleCloseFeed(feed)
res.sendStatus(200)
} }
/** /**

View File

@ -2,6 +2,9 @@ const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const RssFeedManager = require('../managers/RssFeedManager')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters') const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
/** /**
@ -51,8 +54,8 @@ class SeriesController {
} }
if (include.includes('rssfeed')) { if (include.includes('rssfeed')) {
const feedObj = await this.rssFeedManager.findFeedForEntityId(seriesJson.id) const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
seriesJson.rssFeed = feedObj?.toJSONMinified() || null seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
} }
res.json(seriesJson) res.json(seriesJson)

View File

@ -6,76 +6,139 @@ const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const Feed = require('../objects/Feed')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
class RssFeedManager { class RssFeedManager {
constructor() {} constructor() {}
async validateFeedEntity(feedObj) {
if (feedObj.entityType === 'collection') {
const collection = await Database.collectionModel.getOldById(feedObj.entityId)
if (!collection) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Collection "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'libraryItem') {
const libraryItemExists = await Database.libraryItemModel.checkExistsById(feedObj.entityId)
if (!libraryItemExists) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Library item "${feedObj.entityId}" not found`)
return false
}
} else if (feedObj.entityType === 'series') {
const series = await Database.seriesModel.findByPk(feedObj.entityId)
if (!series) {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Series "${feedObj.entityId}" not found`)
return false
}
} else {
Logger.error(`[RssFeedManager] Removing feed "${feedObj.id}". Invalid entityType "${feedObj.entityType}"`)
return false
}
return true
}
/** /**
* Validate all feeds and remove invalid * Remove invalid feeds (invalid if the entity does not exist)
*/ */
async init() { async init() {
const feeds = await Database.feedModel.getOldFeeds() const feeds = await Database.feedModel.findAll({
for (const feed of feeds) { attributes: ['id', 'entityId', 'entityType', 'title'],
// Remove invalid feeds include: [
if (!(await this.validateFeedEntity(feed))) { {
await Database.removeFeed(feed.id) model: Database.libraryItemModel,
attributes: ['id']
},
{
model: Database.collectionModel,
attributes: ['id']
},
{
model: Database.seriesModel,
attributes: ['id']
} }
]
})
const feedIdsToRemove = []
for (const feed of feeds) {
if (!feed.entity) {
Logger.error(`[RssFeedManager] Removing feed "${feed.title}". Entity not found`)
feedIdsToRemove.push(feed.id)
}
}
if (feedIdsToRemove.length) {
Logger.info(`[RssFeedManager] Removing ${feedIdsToRemove.length} invalid feeds`)
await Database.feedModel.destroy({
where: {
id: feedIdsToRemove
}
})
} }
} }
/** /**
* Find open feed for an entity (e.g. collection id, playlist id, library item id) * Find open feed for an entity (e.g. collection id, playlist id, library item id)
* @param {string} entityId * @param {string} entityId
* @returns {Promise<objects.Feed>} oldFeed * @returns {Promise<import('../models/Feed')>}
*/ */
findFeedForEntityId(entityId) { findFeedForEntityId(entityId) {
return Database.feedModel.findOneOld({ entityId }) return Database.feedModel.findOne({
where: {
entityId
}
})
} }
/** /**
* Find open feed for a slug *
* @param {string} slug * @param {string} slug
* @returns {Promise<objects.Feed>} oldFeed * @returns {Promise<boolean>}
*/ */
findFeedBySlug(slug) { checkExistsBySlug(slug) {
return Database.feedModel.findOneOld({ slug }) return Database.feedModel
.count({
where: {
slug
}
})
.then((count) => count > 0)
} }
/** /**
* Find open feed for a slug * Feed requires update if the entity (or child entities) has been updated since the feed was last updated
* @param {string} slug *
* @returns {Promise<objects.Feed>} oldFeed * @param {import('../models/Feed')} feed
* @returns {Promise<boolean>}
*/ */
findFeed(id) { async checkFeedRequiresUpdate(feed) {
return Database.feedModel.findByPkOld(id) if (feed.entityType === 'libraryItem') {
feed.entity = await feed.getEntity({
attributes: ['id', 'updatedAt', 'mediaId', 'mediaType']
})
let newEntityUpdatedAt = feed.entity.updatedAt
if (feed.entity.mediaType === 'podcast') {
const mostRecentPodcastEpisode = await Database.podcastEpisodeModel.findOne({
where: {
podcastId: feed.entity.mediaId
},
attributes: ['id', 'updatedAt'],
order: [['createdAt', 'DESC']]
})
if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt
}
}
return newEntityUpdatedAt > feed.entityUpdatedAt
} else if (feed.entityType === 'collection' || feed.entityType === 'series') {
feed.entity = await feed.getEntity({
attributes: ['id', 'updatedAt'],
include: {
model: Database.bookModel,
attributes: ['id'],
through: {
attributes: []
},
include: {
model: Database.libraryItemModel,
attributes: ['id', 'updatedAt']
}
}
})
let newEntityUpdatedAt = feed.entity.updatedAt
const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => {
if (book.libraryItem.updatedAt > mostRecent) {
return book.libraryItem.updatedAt
}
return mostRecent
}, 0)
if (mostRecentItemUpdatedAt > newEntityUpdatedAt) {
newEntityUpdatedAt = mostRecentItemUpdatedAt
}
return newEntityUpdatedAt > feed.entityUpdatedAt
} else {
throw new Error('Invalid feed entity type')
}
} }
/** /**
@ -85,88 +148,23 @@ class RssFeedManager {
* @param {Response} res * @param {Response} res
*/ */
async getFeed(req, res) { async getFeed(req, res) {
const feed = await this.findFeedBySlug(req.params.slug) let feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
}
})
if (!feed) { if (!feed) {
Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`) Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404) res.sendStatus(404)
return return
} }
// Check if feed needs to be updated const feedRequiresUpdate = await this.checkFeedRequiresUpdate(feed)
if (feed.entityType === 'libraryItem') { if (feedRequiresUpdate) {
const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId) Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`)
feed = await feed.updateFeedForEntity()
let mostRecentlyUpdatedAt = libraryItem.updatedAt } else {
if (libraryItem.isPodcast) { feed.feedEpisodes = await feed.getFeedEpisodes()
libraryItem.media.episodes.forEach((episode) => {
if (episode.updatedAt > mostRecentlyUpdatedAt) mostRecentlyUpdatedAt = episode.updatedAt
})
}
if (libraryItem && (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt)) {
Logger.debug(`[RssFeedManager] Updating RSS feed for item ${libraryItem.id} "${libraryItem.media.metadata.title}"`)
feed.updateFromItem(libraryItem)
await Database.updateFeed(feed)
}
} else if (feed.entityType === 'collection') {
const collection = await Database.collectionModel.findByPk(feed.entityId, {
include: Database.collectionBookModel
})
if (collection) {
const collectionExpanded = await collection.getOldJsonExpanded()
// Find most recently updated item in collection
let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate
// Check for most recently updated book
collectionExpanded.books.forEach((libraryItem) => {
if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = libraryItem.updatedAt
}
})
// Check for most recently added collection book
collection.collectionBooks.forEach((collectionBook) => {
if (collectionBook.createdAt.valueOf() > mostRecentlyUpdatedAt) {
mostRecentlyUpdatedAt = collectionBook.createdAt.valueOf()
}
})
const hasBooksRemoved = collection.collectionBooks.length < feed.episodes.length
if (!feed.entityUpdatedAt || hasBooksRemoved || mostRecentlyUpdatedAt > feed.entityUpdatedAt) {
Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`)
feed.updateFromCollection(collectionExpanded)
await Database.updateFeed(feed)
}
}
} else if (feed.entityType === 'series') {
const series = await Database.seriesModel.findByPk(feed.entityId)
if (series) {
const seriesJson = series.toOldJSON()
// Get books in series that have audio tracks
seriesJson.books = (await libraryItemsBookFilters.getLibraryItemsForSeries(series)).filter((li) => li.media.numTracks)
// 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 Database.updateFeed(feed)
}
}
} }
const xml = feed.buildXml(req.originalHostPrefix) const xml = feed.buildXml(req.originalHostPrefix)
@ -181,7 +179,17 @@ class RssFeedManager {
* @param {Response} res * @param {Response} res
*/ */
async getFeedItem(req, res) { async getFeedItem(req, res) {
const feed = await this.findFeedBySlug(req.params.slug) const feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
},
attributes: ['id', 'slug'],
include: {
model: Database.feedEpisodeModel,
attributes: ['id', 'filePath']
}
})
if (!feed) { if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`) Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404) res.sendStatus(404)
@ -203,7 +211,12 @@ class RssFeedManager {
* @param {Response} res * @param {Response} res
*/ */
async getFeedCover(req, res) { async getFeedCover(req, res) {
const feed = await this.findFeedBySlug(req.params.slug) const feed = await Database.feedModel.findOne({
where: {
slug: req.params.slug
},
attributes: ['coverPath']
})
if (!feed) { if (!feed) {
Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`) Logger.debug(`[RssFeedManager] Feed not found ${req.params.slug}`)
res.sendStatus(404) res.sendStatus(404)
@ -264,76 +277,102 @@ class RssFeedManager {
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {*} collectionExpanded * @param {import('../models/Collection')} collectionExpanded
* @param {*} options * @param {*} options
* @returns * @returns {Promise<import('../models/Feed').FeedExpanded>}
*/ */
async openFeedForCollection(userId, collectionExpanded, options) { async openFeedForCollection(userId, collectionExpanded, options) {
const serverAddress = options.serverAddress const serverAddress = options.serverAddress
const slug = options.slug const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true const feedOptions = this.getFeedOptionsFromReqOptions(options)
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed() Logger.info(`[RssFeedManager] Creating RSS feed for collection "${collectionExpanded.name}"`)
feed.setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing, ownerName, ownerEmail) const feedExpanded = await Database.feedModel.createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
await Database.createFeed(feed) SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) }
return feed return feedExpanded
} }
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {*} seriesExpanded * @param {import('../models/Series')} seriesExpanded
* @param {*} options * @param {*} options
* @returns * @returns {Promise<import('../models/Feed').FeedExpanded>}
*/ */
async openFeedForSeries(userId, seriesExpanded, options) { async openFeedForSeries(userId, seriesExpanded, options) {
const serverAddress = options.serverAddress const serverAddress = options.serverAddress
const slug = options.slug const slug = options.slug
const preventIndexing = options.metadataDetails?.preventIndexing ?? true const feedOptions = this.getFeedOptionsFromReqOptions(options)
const ownerName = options.metadataDetails?.ownerName
const ownerEmail = options.metadataDetails?.ownerEmail
const feed = new Feed() Logger.info(`[RssFeedManager] Creating RSS feed for series "${seriesExpanded.name}"`)
feed.setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing, ownerName, ownerEmail) const feedExpanded = await Database.feedModel.createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
if (feedExpanded) {
Logger.info(`[RssFeedManager] Opened RSS feed "${feed.feedUrl}"`) Logger.info(`[RssFeedManager] Opened RSS feed "${feedExpanded.feedURL}"`)
await Database.createFeed(feed) SocketAuthority.emitter('rss_feed_open', feedExpanded.toOldJSONMinified())
SocketAuthority.emitter('rss_feed_open', feed.toJSONMinified()) }
return feed return feedExpanded
} }
/**
* Close Feed and emit Socket event
*
* @param {import('../models/Feed')} feed
* @returns {Promise<boolean>} - true if feed was closed
*/
async handleCloseFeed(feed) { async handleCloseFeed(feed) {
if (!feed) return if (!feed) return false
await Database.removeFeed(feed.id) const wasRemoved = await Database.feedModel.removeById(feed.id)
SocketAuthority.emitter('rss_feed_closed', feed.toJSONMinified()) SocketAuthority.emitter('rss_feed_closed', feed.toOldJSONMinified())
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedUrl}"`) Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedURL}"`)
} return wasRemoved
async closeRssFeed(req, res) {
const feed = await this.findFeed(req.params.id)
if (!feed) {
Logger.error(`[RssFeedManager] RSS feed not found with id "${req.params.id}"`)
return res.sendStatus(404)
}
await this.handleCloseFeed(feed)
res.sendStatus(200)
} }
/**
*
* @param {string} entityId
* @returns {Promise<boolean>} - true if feed was closed
*/
async closeFeedForEntityId(entityId) { async closeFeedForEntityId(entityId) {
const feed = await this.findFeedForEntityId(entityId) const feed = await Database.feedModel.findOne({
if (!feed) return where: {
entityId
}
})
if (!feed) {
Logger.warn(`[RssFeedManager] closeFeedForEntityId: Feed not found for entity id ${entityId}`)
return false
}
return this.handleCloseFeed(feed) return this.handleCloseFeed(feed)
} }
async getFeeds() { /**
const feeds = await Database.models.feed.getOldFeeds() *
Logger.info(`[RssFeedManager] Fetched all feeds`) * @param {string[]} entityIds
return feeds */
async closeFeedsForEntityIds(entityIds) {
const feeds = await Database.feedModel.findAll({
where: {
entityId: entityIds
}
})
for (const feed of feeds) {
await this.handleCloseFeed(feed)
} }
} }
module.exports = RssFeedManager
/**
*
* @returns {Promise<import('../models/Feed').FeedExpanded[]>}
*/
getFeeds() {
return Database.feedModel.findAll({
include: {
model: Database.feedEpisodeModel
}
})
}
}
module.exports = new RssFeedManager()

View File

@ -29,6 +29,12 @@ const Logger = require('../Logger')
* @property {SeriesExpanded[]} series * @property {SeriesExpanded[]} series
* *
* @typedef {Book & BookExpandedProperties} BookExpanded * @typedef {Book & BookExpandedProperties} BookExpanded
*
* Collections use BookExpandedWithLibraryItem
* @typedef BookExpandedWithLibraryItemProperties
* @property {import('./LibraryItem')} libraryItem
*
* @typedef {BookExpanded & BookExpandedWithLibraryItemProperties} BookExpandedWithLibraryItem
*/ */
/** /**

View File

@ -18,6 +18,11 @@ class Collection extends Model {
this.updatedAt this.updatedAt
/** @type {Date} */ /** @type {Date} */
this.createdAt this.createdAt
// Expanded properties
/** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */
this.books
} }
/** /**
@ -107,7 +112,7 @@ class Collection extends Model {
// Map feed if found // Map feed if found
if (c.feeds?.length) { if (c.feeds?.length) {
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(c.feeds[0]) collectionExpanded.rssFeed = c.feeds[0].toOldJSON()
} }
return collectionExpanded return collectionExpanded
@ -115,6 +120,39 @@ class Collection extends Model {
.filter((c) => c) .filter((c) => c)
} }
/**
*
* @param {string} collectionId
* @returns {Promise<Collection>}
*/
static async getExpandedById(collectionId) {
return this.findByPk(collectionId, {
include: [
{
model: this.sequelize.models.book,
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
]
}
],
order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']]
})
}
/** /**
* Get old collection from Collection * Get old collection from Collection
* @param {Collection} collectionExpanded * @param {Collection} collectionExpanded
@ -219,6 +257,34 @@ class Collection extends Model {
Collection.belongsTo(library) Collection.belongsTo(library)
} }
/**
* Get all books in collection expanded with library item
*
* @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}
*/
getBooksExpandedWithLibraryItem() {
return this.getBooks({
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [Sequelize.literal('`collectionBook.order` ASC')]
})
}
/** /**
* Get old collection toJSONExpanded, items filtered for user permissions * Get old collection toJSONExpanded, items filtered for user permissions
* *
@ -282,7 +348,7 @@ class Collection extends Model {
if (include?.includes('rssfeed')) { if (include?.includes('rssfeed')) {
const feeds = await this.getFeeds() const feeds = await this.getFeeds()
if (feeds?.length) { if (feeds?.length) {
collectionExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0]) collectionExpanded.rssFeed = feeds[0].toOldJSON()
} }
} }

View File

@ -1,7 +1,8 @@
const Path = require('path') const Path = require('path')
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const oldFeed = require('../objects/Feed') const Logger = require('../Logger')
const areEquivalent = require('../utils/areEquivalent')
const RSS = require('../libs/rss')
/** /**
* @typedef FeedOptions * @typedef FeedOptions
@ -66,234 +67,53 @@ class Feed extends Model {
/** @type {Date} */ /** @type {Date} */
this.updatedAt this.updatedAt
// Expanded properties
/** @type {import('./FeedEpisode')[]} - only set if expanded */ /** @type {import('./FeedEpisode')[]} - only set if expanded */
this.feedEpisodes this.feedEpisodes
} }
static async getOldFeeds() {
const feeds = await this.findAll({
include: {
model: this.sequelize.models.feedEpisode
}
})
return feeds.map((f) => this.getOldFeed(f))
}
/** /**
* Get old feed from Feed and optionally Feed with FeedEpisodes * @param {string} feedId
* @param {Feed} feedExpanded * @returns {Promise<boolean>} - true if feed was removed
* @returns {oldFeed}
*/ */
static getOldFeed(feedExpanded) { static async removeById(feedId) {
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) || [] return (
(await this.destroy({
// Sort episodes by pubDate. Newest to oldest for episodic, oldest to newest for serial
if (feedExpanded.podcastType === 'episodic') {
episodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
} else {
episodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate))
}
return new oldFeed({
id: feedExpanded.id,
slug: feedExpanded.slug,
userId: feedExpanded.userId,
entityType: feedExpanded.entityType,
entityId: feedExpanded.entityId,
entityUpdatedAt: feedExpanded.entityUpdatedAt?.valueOf() || null,
coverPath: feedExpanded.coverPath || null,
meta: {
title: feedExpanded.title,
description: feedExpanded.description,
author: feedExpanded.author,
imageUrl: feedExpanded.imageURL,
feedUrl: feedExpanded.feedURL,
link: feedExpanded.siteURL,
explicit: feedExpanded.explicit,
type: feedExpanded.podcastType,
language: feedExpanded.language,
preventIndexing: feedExpanded.preventIndexing,
ownerName: feedExpanded.ownerName,
ownerEmail: feedExpanded.ownerEmail
},
serverAddress: feedExpanded.serverAddress,
feedUrl: feedExpanded.feedURL,
episodes,
createdAt: feedExpanded.createdAt.valueOf(),
updatedAt: feedExpanded.updatedAt.valueOf()
})
}
static removeById(feedId) {
return this.destroy({
where: { where: {
id: feedId id: feedId
} }
}) })) > 0
} )
/**
* Find all library item ids that have an open feed (used in library filter)
* @returns {Promise<string[]>} array of library item ids
*/
static async findAllLibraryItemIds() {
const feeds = await this.findAll({
attributes: ['entityId'],
where: {
entityType: 'libraryItem'
}
})
return feeds.map((f) => f.entityId).filter((f) => f) || []
}
/**
* Find feed where and return oldFeed
* @param {Object} where sequelize where object
* @returns {Promise<oldFeed>} oldFeed
*/
static async findOneOld(where) {
if (!where) return null
const feedExpanded = await this.findOne({
where,
include: {
model: this.sequelize.models.feedEpisode
}
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
/**
* Find feed and return oldFeed
* @param {string} id
* @returns {Promise<oldFeed>} oldFeed
*/
static async findByPkOld(id) {
if (!id) return null
const feedExpanded = await this.findByPk(id, {
include: {
model: this.sequelize.models.feedEpisode
}
})
if (!feedExpanded) return null
return this.getOldFeed(feedExpanded)
}
static async fullCreateFromOld(oldFeed) {
const feedObj = this.getFromOld(oldFeed)
const newFeed = await this.create(feedObj)
if (oldFeed.episodes?.length) {
for (const oldFeedEpisode of oldFeed.episodes) {
const feedEpisode = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
feedEpisode.feedId = newFeed.id
await this.sequelize.models.feedEpisode.create(feedEpisode)
}
}
}
static async fullUpdateFromOld(oldFeed) {
const oldFeedEpisodes = oldFeed.episodes || []
const feedObj = this.getFromOld(oldFeed)
const existingFeed = await this.findByPk(feedObj.id, {
include: this.sequelize.models.feedEpisode
})
if (!existingFeed) return false
let hasUpdates = false
// Remove and update existing feed episodes
for (const feedEpisode of existingFeed.feedEpisodes) {
const oldFeedEpisode = oldFeedEpisodes.find((ep) => ep.id === feedEpisode.id)
// Episode removed
if (!oldFeedEpisode) {
feedEpisode.destroy()
} else {
let episodeHasUpdates = false
const oldFeedEpisodeCleaned = this.sequelize.models.feedEpisode.getFromOld(oldFeedEpisode)
for (const key in oldFeedEpisodeCleaned) {
if (!areEquivalent(oldFeedEpisodeCleaned[key], feedEpisode[key])) {
episodeHasUpdates = true
}
}
if (episodeHasUpdates) {
await feedEpisode.update(oldFeedEpisodeCleaned)
hasUpdates = true
}
}
}
// Add new feed episodes
for (const episode of oldFeedEpisodes) {
if (!existingFeed.feedEpisodes.some((fe) => fe.id === episode.id)) {
await this.sequelize.models.feedEpisode.createFromOld(feedObj.id, episode)
hasUpdates = true
}
}
let feedHasUpdates = false
for (const key in feedObj) {
let existingValue = existingFeed[key]
if (existingValue instanceof Date) existingValue = existingValue.valueOf()
if (!areEquivalent(existingValue, feedObj[key])) {
feedHasUpdates = true
}
}
if (feedHasUpdates) {
await existingFeed.update(feedObj)
hasUpdates = true
}
return hasUpdates
}
static getFromOld(oldFeed) {
const oldFeedMeta = oldFeed.meta || {}
return {
id: oldFeed.id,
slug: oldFeed.slug,
entityType: oldFeed.entityType,
entityId: oldFeed.entityId,
entityUpdatedAt: oldFeed.entityUpdatedAt,
serverAddress: oldFeed.serverAddress,
feedURL: oldFeed.feedUrl,
coverPath: oldFeed.coverPath || null,
imageURL: oldFeedMeta.imageUrl,
siteURL: oldFeedMeta.link,
title: oldFeedMeta.title,
description: oldFeedMeta.description,
author: oldFeedMeta.author,
podcastType: oldFeedMeta.type || null,
language: oldFeedMeta.language || null,
ownerName: oldFeedMeta.ownerName || null,
ownerEmail: oldFeedMeta.ownerEmail || null,
explicit: !!oldFeedMeta.explicit,
preventIndexing: !!oldFeedMeta.preventIndexing,
userId: oldFeed.userId
}
} }
/** /**
* *
* @param {string} userId * @param {string} userId
* @param {string} slug
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem * @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
* @param {string} slug
* @param {string} serverAddress * @param {string} serverAddress
* @param {FeedOptions} feedOptions * @param {FeedOptions} [feedOptions=null]
* *
* @returns {Promise<FeedExpanded>} * @returns {Feed}
*/ */
static async createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) { static getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions = null) {
const media = libraryItem.media const media = libraryItem.media
let entityUpdatedAt = libraryItem.updatedAt
// Podcast feeds should use the most recent episode updatedAt if more recent
if (libraryItem.mediaType === 'podcast') {
entityUpdatedAt = libraryItem.media.podcastEpisodes.reduce((mostRecent, episode) => {
return episode.updatedAt > mostRecent ? episode.updatedAt : mostRecent
}, entityUpdatedAt)
}
const feedObj = { const feedObj = {
slug, slug,
entityType: 'libraryItem', entityType: 'libraryItem',
entityId: libraryItem.id, entityId: libraryItem.id,
entityUpdatedAt: libraryItem.updatedAt, entityUpdatedAt,
serverAddress, serverAddress,
feedURL: `/feed/${slug}`, feedURL: `/feed/${slug}`,
imageURL: media.coverPath ? `/feed/${slug}/cover${Path.extname(media.coverPath)}` : `/Logo.png`, imageURL: media.coverPath ? `/feed/${slug}/cover${Path.extname(media.coverPath)}` : `/Logo.png`,
@ -303,14 +123,33 @@ class Feed extends Model {
author: libraryItem.mediaType === 'podcast' ? media.author : media.authorName, author: libraryItem.mediaType === 'podcast' ? media.author : media.authorName,
podcastType: libraryItem.mediaType === 'podcast' ? media.podcastType : 'serial', podcastType: libraryItem.mediaType === 'podcast' ? media.podcastType : 'serial',
language: media.language, language: media.language,
preventIndexing: feedOptions.preventIndexing,
ownerName: feedOptions.ownerName,
ownerEmail: feedOptions.ownerEmail,
explicit: media.explicit, explicit: media.explicit,
coverPath: media.coverPath, coverPath: media.coverPath,
userId userId
} }
if (feedOptions) {
feedObj.preventIndexing = feedOptions.preventIndexing
feedObj.ownerName = feedOptions.ownerName
feedObj.ownerEmail = feedOptions.ownerEmail
}
return feedObj
}
/**
*
* @param {string} userId
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} feedOptions
*
* @returns {Promise<FeedExpanded>}
*/
static async createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) {
const feedObj = this.getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions)
/** @type {typeof import('./FeedEpisode')} */ /** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode const feedEpisodeModel = this.sequelize.models.feedEpisode
@ -334,6 +173,183 @@ class Feed extends Model {
} }
} }
/**
*
* @param {string} userId
* @param {import('./Collection')} collectionExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} [feedOptions=null]
*
* @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }}
*/
static getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions = null) {
const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length)
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
}, collectionExpanded.updatedAt)
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => {
const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name)
return authorNames.concat(bookAuthorsToAdd)
}, [])
let author = allBookAuthorNames.slice(0, 3).join(', ')
if (allBookAuthorNames.length > 3) {
author += ' & more'
}
const feedObj = {
slug,
entityType: 'collection',
entityId: collectionExpanded.id,
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
siteURL: `/collection/${collectionExpanded.id}`,
title: collectionExpanded.name,
description: collectionExpanded.description || '',
author,
podcastType: 'serial',
explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit
coverPath: firstBookWithCover?.coverPath || null,
userId
}
if (feedOptions) {
feedObj.preventIndexing = feedOptions.preventIndexing
feedObj.ownerName = feedOptions.ownerName
feedObj.ownerEmail = feedOptions.ownerEmail
}
return {
feedObj,
booksWithTracks
}
}
/**
*
* @param {string} userId
* @param {import('./Collection')} collectionExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} feedOptions
*
* @returns {Promise<FeedExpanded>}
*/
static async createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions) {
const { feedObj, booksWithTracks } = this.getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions)
/** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode
const transaction = await this.sequelize.transaction()
try {
const feed = await this.create(feedObj, { transaction })
feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)
await transaction.commit()
return feed
} catch (error) {
Logger.error(`[Feed] Error creating feed for collection ${collectionExpanded.id}`, error)
await transaction.rollback()
return null
}
}
/**
*
* @param {string} userId
* @param {import('./Series')} seriesExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} [feedOptions=null]
*
* @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }}
*/
static getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions = null) {
const booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length)
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
}, seriesExpanded.updatedAt)
const firstBookWithCover = booksWithTracks.find((book) => book.coverPath)
const allBookAuthorNames = booksWithTracks.reduce((authorNames, book) => {
const bookAuthorsToAdd = book.authors.filter((author) => !authorNames.includes(author.name)).map((author) => author.name)
return authorNames.concat(bookAuthorsToAdd)
}, [])
let author = allBookAuthorNames.slice(0, 3).join(', ')
if (allBookAuthorNames.length > 3) {
author += ' & more'
}
const feedObj = {
slug,
entityType: 'series',
entityId: seriesExpanded.id,
entityUpdatedAt,
serverAddress,
feedURL: `/feed/${slug}`,
imageURL: firstBookWithCover?.coverPath ? `/feed/${slug}/cover${Path.extname(firstBookWithCover.coverPath)}` : `/Logo.png`,
siteURL: `/library/${booksWithTracks[0].libraryItem.libraryId}/series/${seriesExpanded.id}`,
title: seriesExpanded.name,
description: seriesExpanded.description || '',
author,
podcastType: 'serial',
explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit
coverPath: firstBookWithCover?.coverPath || null,
userId
}
if (feedOptions) {
feedObj.preventIndexing = feedOptions.preventIndexing
feedObj.ownerName = feedOptions.ownerName
feedObj.ownerEmail = feedOptions.ownerEmail
}
return {
feedObj,
booksWithTracks
}
}
/**
*
* @param {string} userId
* @param {import('./Series')} seriesExpanded
* @param {string} slug
* @param {string} serverAddress
* @param {FeedOptions} feedOptions
*
* @returns {Promise<FeedExpanded>}
*/
static async createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions) {
const { feedObj, booksWithTracks } = this.getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions)
/** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode
const transaction = await this.sequelize.transaction()
try {
const feed = await this.create(feedObj, { transaction })
feed.feedEpisodes = await feedEpisodeModel.createFromBooks(booksWithTracks, feed, slug, transaction)
await transaction.commit()
return feed
} catch (error) {
Logger.error(`[Feed] Error creating feed for series ${seriesExpanded.id}`, error)
await transaction.rollback()
return null
}
}
/** /**
* Initialize model * Initialize model
* *
@ -448,12 +464,144 @@ class Feed extends Model {
}) })
} }
/**
*
* @returns {Promise<FeedExpanded>}
*/
async updateFeedForEntity() {
/** @type {typeof import('./FeedEpisode')} */
const feedEpisodeModel = this.sequelize.models.feedEpisode
let feedObj = null
let feedEpisodeCreateFunc = null
let feedEpisodeCreateFuncEntity = null
if (this.entityType === 'libraryItem') {
/** @type {typeof import('./LibraryItem')} */
const libraryItemModel = this.sequelize.models.libraryItem
const itemExpanded = await libraryItemModel.getExpandedById(this.entityId)
feedObj = Feed.getFeedObjForLibraryItem(this.userId, itemExpanded, this.slug, this.serverAddress)
feedEpisodeCreateFuncEntity = itemExpanded
if (itemExpanded.mediaType === 'podcast') {
feedEpisodeCreateFunc = feedEpisodeModel.createFromPodcastEpisodes.bind(feedEpisodeModel)
} else {
feedEpisodeCreateFunc = feedEpisodeModel.createFromAudiobookTracks.bind(feedEpisodeModel)
}
} else if (this.entityType === 'collection') {
/** @type {typeof import('./Collection')} */
const collectionModel = this.sequelize.models.collection
const collectionExpanded = await collectionModel.getExpandedById(this.entityId)
const feedObjData = Feed.getFeedObjForCollection(this.userId, collectionExpanded, this.slug, this.serverAddress)
feedObj = feedObjData.feedObj
feedEpisodeCreateFuncEntity = feedObjData.booksWithTracks
feedEpisodeCreateFunc = feedEpisodeModel.createFromBooks.bind(feedEpisodeModel)
} else if (this.entityType === 'series') {
/** @type {typeof import('./Series')} */
const seriesModel = this.sequelize.models.series
const seriesExpanded = await seriesModel.getExpandedById(this.entityId)
const feedObjData = Feed.getFeedObjForSeries(this.userId, seriesExpanded, this.slug, this.serverAddress)
feedObj = feedObjData.feedObj
feedEpisodeCreateFuncEntity = feedObjData.booksWithTracks
feedEpisodeCreateFunc = feedEpisodeModel.createFromBooks.bind(feedEpisodeModel)
} else {
Logger.error(`[Feed] Invalid entity type ${this.entityType} for feed ${this.id}`)
return null
}
const transaction = await this.sequelize.transaction()
try {
const updatedFeed = await this.update(feedObj, { transaction })
// Remove existing feed episodes
await feedEpisodeModel.destroy({
where: {
feedId: this.id
},
transaction
})
// Create new feed episodes
updatedFeed.feedEpisodes = await feedEpisodeCreateFunc(feedEpisodeCreateFuncEntity, updatedFeed, this.slug, transaction)
await transaction.commit()
return updatedFeed
} catch (error) {
Logger.error(`[Feed] Error updating feed ${this.entityId}`, error)
await transaction.rollback()
return null
}
}
getEntity(options) { getEntity(options) {
if (!this.entityType) return Promise.resolve(null) if (!this.entityType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}` const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
return this[mixinMethodName](options) return this[mixinMethodName](options)
} }
/**
*
* @param {string} hostPrefix
*/
buildXml(hostPrefix) {
const blockTags = [{ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }]
const rssData = {
title: this.title,
description: this.description || '',
generator: 'Audiobookshelf',
feed_url: `${hostPrefix}${this.feedURL}`,
site_url: `${hostPrefix}${this.siteURL}`,
image_url: `${hostPrefix}${this.imageURL}`,
custom_namespaces: {
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
psc: 'http://podlove.org/simple-chapters',
podcast: 'https://podcastindex.org/namespace/1.0',
googleplay: 'http://www.google.com/schemas/play-podcasts/1.0'
},
custom_elements: [
{ language: this.language || 'en' },
{ author: this.author || 'advplyr' },
{ 'itunes:author': this.author || 'advplyr' },
{ 'itunes:summary': this.description || '' },
{ 'itunes:type': this.podcastType },
{
'itunes:image': {
_attr: {
href: `${hostPrefix}${this.imageURL}`
}
}
},
{
'itunes:owner': [{ 'itunes:name': this.ownerName || this.author || '' }, { 'itunes:email': this.ownerEmail || '' }]
},
{ 'itunes:explicit': !!this.explicit },
...(this.preventIndexing ? blockTags : [])
]
}
const rssfeed = new RSS(rssData)
this.feedEpisodes.forEach((ep) => {
rssfeed.item(ep.getRSSData(hostPrefix))
})
return rssfeed.xml()
}
/**
*
* @param {string} id
* @returns {string}
*/
getEpisodePath(id) {
const episode = this.feedEpisodes.find((ep) => ep.id === id)
if (!episode) return null
return episode.filePath
}
toOldJSON() { toOldJSON() {
const episodes = this.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) const episodes = this.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
return { return {

View File

@ -3,6 +3,7 @@ const { DataTypes, Model } = require('sequelize')
const uuidv4 = require('uuid').v4 const uuidv4 = require('uuid').v4
const Logger = require('../Logger') const Logger = require('../Logger')
const date = require('../libs/dateAndTime') const date = require('../libs/dateAndTime')
const { secondsToTimestamp } = require('../utils')
class FeedEpisode extends Model { class FeedEpisode extends Model {
constructor(values, options) { constructor(values, options) {
@ -13,6 +14,8 @@ class FeedEpisode extends Model {
/** @type {string} */ /** @type {string} */
this.title this.title
/** @type {string} */ /** @type {string} */
this.author
/** @type {string} */
this.description this.description
/** @type {string} */ /** @type {string} */
this.siteURL this.siteURL
@ -44,39 +47,6 @@ class FeedEpisode extends Model {
this.updatedAt this.updatedAt
} }
/**
* Create feed episode from old model
*
* @param {string} feedId
* @param {Object} oldFeedEpisode
* @returns {Promise<FeedEpisode>}
*/
static createFromOld(feedId, oldFeedEpisode) {
const newEpisode = this.getFromOld(oldFeedEpisode)
newEpisode.feedId = feedId
return this.create(newEpisode)
}
static getFromOld(oldFeedEpisode) {
return {
id: oldFeedEpisode.id,
title: oldFeedEpisode.title,
author: oldFeedEpisode.author,
description: oldFeedEpisode.description,
siteURL: oldFeedEpisode.link,
enclosureURL: oldFeedEpisode.enclosure?.url || null,
enclosureType: oldFeedEpisode.enclosure?.type || null,
enclosureSize: oldFeedEpisode.enclosure?.size || null,
pubDate: oldFeedEpisode.pubDate,
season: oldFeedEpisode.season || null,
episode: oldFeedEpisode.episode || null,
episodeType: oldFeedEpisode.episodeType || null,
duration: oldFeedEpisode.duration,
filePath: oldFeedEpisode.fullPath,
explicit: !!oldFeedEpisode.explicit
}
}
/** /**
* *
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded * @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
@ -85,12 +55,14 @@ class FeedEpisode extends Model {
* @param {import('./PodcastEpisode')} episode * @param {import('./PodcastEpisode')} episode
*/ */
static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) { static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) {
const episodeId = uuidv4()
return { return {
id: episodeId,
title: episode.title, title: episode.title,
author: feed.author, author: feed.author,
description: episode.description, description: episode.description,
siteURL: feed.siteURL, siteURL: feed.siteURL,
enclosureURL: `/feed/${slug}/item/${episode.id}/media${Path.extname(episode.audioFile.metadata.filename)}`, enclosureURL: `/feed/${slug}/item/${episodeId}/media${Path.extname(episode.audioFile.metadata.filename)}`,
enclosureType: episode.audioFile.mimeType, enclosureType: episode.audioFile.mimeType,
enclosureSize: episode.audioFile.metadata.size, enclosureSize: episode.audioFile.metadata.size,
pubDate: episode.pubDate, pubDate: episode.pubDate,
@ -132,12 +104,12 @@ class FeedEpisode extends Model {
/** /**
* If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names * If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names
* *
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded * @param {import('./Book')} book
* @returns {boolean} * @returns {boolean}
*/ */
static checkUseChapterTitlesForEpisodes(libraryItemExpanded) { static checkUseChapterTitlesForEpisodes(book) {
const tracks = libraryItemExpanded.media.trackList || [] const tracks = book.trackList || []
const chapters = libraryItemExpanded.media.chapters || [] const chapters = book.chapters || []
if (tracks.length !== chapters.length) return false if (tracks.length !== chapters.length) return false
for (let i = 0; i < tracks.length; i++) { for (let i = 0; i < tracks.length; i++) {
if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) { if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) {
@ -149,32 +121,31 @@ class FeedEpisode extends Model {
/** /**
* *
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded * @param {import('./Book')} book
* @param {Date} pubDateStart
* @param {import('./Feed')} feed * @param {import('./Feed')} feed
* @param {string} slug * @param {string} slug
* @param {import('./Book').AudioFileObject} audioTrack * @param {import('./Book').AudioFileObject} audioTrack
* @param {boolean} useChapterTitles * @param {boolean} useChapterTitles
* @param {string} [pubDateOverride]
*/ */
static getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded, feed, slug, audioTrack, useChapterTitles, pubDateOverride = null) { static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate> // Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order
let episodeId = uuidv4() let episodeId = uuidv4()
// e.g. Track 1 will have a pub date before Track 2 // e.g. Track 1 will have a pub date before Track 2
const audiobookPubDate = pubDateOverride || date.format(new Date(libraryItemExpanded.createdAt.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]') const audiobookPubDate = date.format(new Date(pubDateStart.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}` const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}`
const media = libraryItemExpanded.media
let title = audioTrack.title let title = audioTrack.title
if (media.trackList.length == 1) { if (book.trackList.length == 1) {
// If audiobook is a single file, use book title instead of chapter/file title // If audiobook is a single file, use book title instead of chapter/file title
title = media.title title = book.title
} else { } else {
if (useChapterTitles) { if (useChapterTitles) {
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title // If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
const matchingChapter = media.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1) const matchingChapter = book.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1)
if (matchingChapter?.title) title = matchingChapter.title if (matchingChapter?.title) title = matchingChapter.title
} }
} }
@ -183,7 +154,7 @@ class FeedEpisode extends Model {
id: episodeId, id: episodeId,
title, title,
author: feed.author, author: feed.author,
description: media.description || '', description: book.description || '',
siteURL: feed.siteURL, siteURL: feed.siteURL,
enclosureURL: contentUrl, enclosureURL: contentUrl,
enclosureType: audioTrack.mimeType, enclosureType: audioTrack.mimeType,
@ -191,7 +162,7 @@ class FeedEpisode extends Model {
pubDate: audiobookPubDate, pubDate: audiobookPubDate,
duration: audioTrack.duration, duration: audioTrack.duration,
filePath: audioTrack.metadata.path, filePath: audioTrack.metadata.path,
explicit: media.explicit, explicit: book.explicit,
feedId: feed.id feedId: feed.id
} }
} }
@ -205,11 +176,35 @@ class FeedEpisode extends Model {
* @returns {Promise<FeedEpisode[]>} * @returns {Promise<FeedEpisode[]>}
*/ */
static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) { static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) {
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded) const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded.media)
const feedEpisodeObjs = [] const feedEpisodeObjs = []
for (const track of libraryItemExpanded.media.trackList) { for (const track of libraryItemExpanded.media.trackList) {
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded, feed, slug, track, useChapterTitles)) feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles))
}
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
return this.bulkCreate(feedEpisodeObjs, { transaction })
}
/**
*
* @param {import('./Book')[]} books
* @param {import('./Feed')} feed
* @param {string} slug
* @param {import('sequelize').Transaction} transaction
* @returns {Promise<FeedEpisode[]>}
*/
static async createFromBooks(books, feed, slug, transaction) {
const earliestLibraryItemCreatedAt = books.reduce((earliest, book) => {
return book.libraryItem.createdAt < earliest.libraryItem.createdAt ? book : earliest
}).libraryItem.createdAt
const feedEpisodeObjs = []
for (const book of books) {
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book)
for (const track of book.trackList) {
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles))
}
} }
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`) Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
return this.bulkCreate(feedEpisodeObjs, { transaction }) return this.bulkCreate(feedEpisodeObjs, { transaction })
@ -278,6 +273,37 @@ class FeedEpisode extends Model {
fullPath: this.filePath fullPath: this.filePath
} }
} }
/**
*
* @param {string} hostPrefix
*/
getRSSData(hostPrefix) {
return {
title: this.title,
description: this.description || '',
url: `${hostPrefix}${this.siteURL}`,
guid: `${hostPrefix}${this.enclosureURL}`,
author: this.author,
date: this.pubDate,
enclosure: {
url: `${hostPrefix}${this.enclosureURL}`,
type: this.enclosureType,
size: this.enclosureSize
},
custom_elements: [
{ 'itunes:author': this.author },
{ 'itunes:duration': secondsToTimestamp(this.duration) },
{ 'itunes:summary': this.description || '' },
{
'itunes:explicit': !!this.explicit
},
{ 'itunes:episodeType': this.episodeType },
{ 'itunes:season': this.season },
{ 'itunes:episode': this.episode }
]
}
}
} }
module.exports = FeedEpisode module.exports = FeedEpisode

View File

@ -568,7 +568,7 @@ class LibraryItem extends Model {
oldLibraryItem.media.metadata.series = li.series oldLibraryItem.media.metadata.series = li.series
} }
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = this.sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.media.numEpisodes) { if (li.media.numEpisodes) {
oldLibraryItem.media.numEpisodes = li.media.numEpisodes oldLibraryItem.media.numEpisodes = li.media.numEpisodes

View File

@ -84,13 +84,6 @@ class Playlist extends Model {
const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems) const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems)
if (include?.includes('rssfeed')) {
const feeds = await this.getFeeds()
if (feeds?.length) {
playlistExpanded.rssFeed = this.sequelize.models.feed.getOldFeed(feeds[0])
}
}
return playlistExpanded return playlistExpanded
} }

View File

@ -1,4 +1,4 @@
const { DataTypes, Model, where, fn, col } = require('sequelize') const { DataTypes, Model, where, fn, col, literal } = require('sequelize')
const { getTitlePrefixAtEnd } = require('../utils/index') const { getTitlePrefixAtEnd } = require('../utils/index')
@ -20,6 +20,11 @@ class Series extends Model {
this.createdAt this.createdAt
/** @type {Date} */ /** @type {Date} */
this.updatedAt this.updatedAt
// Expanded properties
/** @type {import('./Book').BookExpandedWithLibraryItem[]} - only set when expanded */
this.books
} }
/** /**
@ -49,6 +54,18 @@ class Series extends Model {
}) })
} }
/**
*
* @param {string} seriesId
* @returns {Promise<Series>}
*/
static async getExpandedById(seriesId) {
const series = await this.findByPk(seriesId)
if (!series) return null
series.books = await series.getBooksExpandedWithLibraryItem()
return series
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
@ -103,6 +120,35 @@ class Series extends Model {
Series.belongsTo(library) Series.belongsTo(library)
} }
/**
* Get all books in collection expanded with library item
*
* @returns {Promise<import('./Book').BookExpandedWithLibraryItem[]>}
*/
getBooksExpandedWithLibraryItem() {
return this.getBooks({
joinTableAttributes: ['sequence'],
include: [
{
model: this.sequelize.models.libraryItem
},
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [[literal('CAST(`bookSeries.sequence` AS FLOAT) ASC NULLS LAST')]]
})
}
toOldJSON() { toOldJSON() {
return { return {
id: this.id, id: this.id,

View File

@ -1,360 +0,0 @@
const Path = require('path')
const uuidv4 = require('uuid').v4
const FeedMeta = require('./FeedMeta')
const FeedEpisode = require('./FeedEpisode')
const date = require('../libs/dateAndTime')
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) {
this.id = null
this.slug = null
this.userId = null
this.entityType = null
this.entityId = null
this.entityUpdatedAt = null
this.coverPath = null
this.serverAddress = null
this.feedUrl = null
this.meta = null
this.episodes = null
this.createdAt = null
this.updatedAt = null
if (feed) {
this.construct(feed)
}
}
construct(feed) {
this.id = feed.id
this.slug = feed.slug
this.userId = feed.userId
this.entityType = feed.entityType
this.entityId = feed.entityId
this.entityUpdatedAt = feed.entityUpdatedAt
this.coverPath = feed.coverPath
this.serverAddress = feed.serverAddress
this.feedUrl = feed.feedUrl
this.meta = new FeedMeta(feed.meta)
this.episodes = feed.episodes.map((ep) => new FeedEpisode(ep))
this.createdAt = feed.createdAt
this.updatedAt = feed.updatedAt
}
toJSON() {
return {
id: this.id,
slug: this.slug,
userId: this.userId,
entityType: this.entityType,
entityId: this.entityId,
coverPath: this.coverPath,
serverAddress: this.serverAddress,
feedUrl: this.feedUrl,
meta: this.meta.toJSON(),
episodes: this.episodes.map((ep) => ep.toJSON()),
createdAt: this.createdAt,
updatedAt: this.updatedAt
}
}
toJSONMinified() {
return {
id: this.id,
entityType: this.entityType,
entityId: this.entityId,
feedUrl: this.feedUrl,
meta: this.meta.toJSONMinified()
}
}
getEpisodePath(id) {
var episode = this.episodes.find((ep) => ep.id === id)
if (!episode) return null
return episode.fullPath
}
/**
* If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names
*
* @param {import('../objects/LibraryItem')} libraryItem
* @returns {boolean}
*/
checkUseChapterTitlesForEpisodes(libraryItem) {
const tracks = libraryItem.media.tracks
const chapters = libraryItem.media.chapters
if (tracks.length !== chapters.length) return false
for (let i = 0; i < tracks.length; i++) {
if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) {
return false
}
}
return true
}
updateFromItem(libraryItem) {
const media = libraryItem.media
const mediaMetadata = media.metadata
const isPodcast = libraryItem.mediaType === 'podcast'
const author = isPodcast ? mediaMetadata.author : mediaMetadata.authorName
this.entityUpdatedAt = libraryItem.updatedAt
this.coverPath = media.coverPath || null
const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null
this.meta.title = mediaMetadata.title
this.meta.description = mediaMetadata.description
this.meta.author = author
this.meta.imageUrl = media.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png`
this.meta.explicit = !!mediaMetadata.explicit
this.meta.type = mediaMetadata.type
this.meta.language = mediaMetadata.language
this.episodes = []
if (isPodcast) {
// PODCAST EPISODES
media.episodes.forEach((episode) => {
if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt
const feedEpisode = new FeedEpisode()
feedEpisode.setFromPodcastEpisode(libraryItem, this.serverAddress, this.slug, episode, this.meta)
this.episodes.push(feedEpisode)
})
} else {
// AUDIOBOOK EPISODES
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItem)
media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
feedEpisode.setFromAudiobookTrack(libraryItem, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles)
this.episodes.push(feedEpisode)
})
}
this.updatedAt = Date.now()
}
setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
const feedUrl = `/feed/${slug}`
const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
this.id = uuidv4()
this.slug = slug
this.userId = userId
this.entityType = 'collection'
this.entityId = collectionExpanded.id
this.entityUpdatedAt = collectionExpanded.lastUpdate // This will be set to the most recently updated library item
this.coverPath = firstItemWithCover?.media.coverPath || null
this.serverAddress = serverAddress
this.feedUrl = feedUrl
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta = new FeedMeta()
this.meta.title = collectionExpanded.name
this.meta.description = collectionExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png`
this.meta.feedUrl = feedUrl
this.meta.link = `/collection/${collectionExpanded.id}`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.meta.preventIndexing = preventIndexing
this.meta.ownerName = ownerName
this.meta.ownerEmail = ownerEmail
this.episodes = []
// Used for calculating pubdate
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
// Offset pubdate to ensure correct order
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
trackTimeOffset += index * 1000 // Offset item
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
this.createdAt = Date.now()
this.updatedAt = Date.now()
}
updateFromCollection(collectionExpanded) {
const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length)
const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath)
this.entityUpdatedAt = collectionExpanded.lastUpdate
this.coverPath = firstItemWithCover?.media.coverPath || null
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta.title = collectionExpanded.name
this.meta.description = collectionExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.episodes = []
// Used for calculating pubdate
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
// Offset pubdate to ensure correct order
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
trackTimeOffset += index * 1000 // Offset item
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
this.updatedAt = Date.now()
}
setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) {
const feedUrl = `/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 = uuidv4()
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?.media.coverPath || null
this.serverAddress = serverAddress
this.feedUrl = feedUrl
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta = new FeedMeta()
this.meta.title = seriesExpanded.name
this.meta.description = seriesExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `/feed/${slug}/cover${coverFileExtension}` : `/Logo.png`
this.meta.feedUrl = feedUrl
this.meta.link = `/library/${libraryId}/series/${seriesExpanded.id}`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.meta.preventIndexing = preventIndexing
this.meta.ownerName = ownerName
this.meta.ownerEmail = ownerEmail
this.episodes = []
// Used for calculating pubdate
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
// Offset pubdate to ensure correct order
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
trackTimeOffset += index * 1000 // Offset item
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
this.createdAt = Date.now()
this.updatedAt = Date.now()
}
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?.media.coverPath || null
const coverFileExtension = this.coverPath ? Path.extname(this.coverPath) : null
this.meta.title = seriesExpanded.name
this.meta.description = seriesExpanded.description || ''
this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks)
this.meta.imageUrl = this.coverPath ? `/feed/${this.slug}/cover${coverFileExtension}` : `/Logo.png`
this.meta.explicit = !!itemsWithTracks.some((li) => li.media.metadata.explicit) // explicit if any item is explicit
this.episodes = []
// Used for calculating pubdate
const earliestItemAddedAt = itemsWithTracks.reduce((earliest, item) => (item.addedAt < earliest ? item.addedAt : earliest), itemsWithTracks[0].addedAt)
itemsWithTracks.forEach((item, index) => {
if (item.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = item.updatedAt
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(item)
item.media.tracks.forEach((audioTrack) => {
const feedEpisode = new FeedEpisode()
// Offset pubdate to ensure correct order
let trackTimeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset track
trackTimeOffset += index * 1000 // Offset item
const episodePubDateOverride = date.format(new Date(earliestItemAddedAt + trackTimeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, episodePubDateOverride)
this.episodes.push(feedEpisode)
})
})
this.updatedAt = Date.now()
}
buildXml(originalHostPrefix) {
var rssfeed = new RSS(this.meta.getRSSData(originalHostPrefix))
this.episodes.forEach((ep) => {
rssfeed.item(ep.getRSSData(originalHostPrefix))
})
return rssfeed.xml()
}
getAuthorsStringFromLibraryItems(libraryItems) {
let itemAuthors = []
libraryItems.forEach((item) => itemAuthors.push(...item.media.metadata.authors.map((au) => au.name)))
itemAuthors = [...new Set(itemAuthors)] // Filter out dupes
let author = itemAuthors.slice(0, 3).join(', ')
if (itemAuthors.length > 3) {
author += ' & more'
}
return author
}
}
module.exports = Feed

View File

@ -1,181 +0,0 @@
const Path = require('path')
const uuidv4 = require('uuid').v4
const date = require('../libs/dateAndTime')
const { secondsToTimestamp } = require('../utils/index')
class FeedEpisode {
constructor(episode) {
this.id = null
this.title = null
this.description = null
this.enclosure = null
this.pubDate = null
this.link = null
this.author = null
this.explicit = null
this.duration = null
this.season = null
this.episode = null
this.episodeType = null
this.libraryItemId = null
this.episodeId = null
this.trackIndex = null
this.fullPath = null
if (episode) {
this.construct(episode)
}
}
construct(episode) {
this.id = episode.id
this.title = episode.title
this.description = episode.description
this.enclosure = episode.enclosure ? { ...episode.enclosure } : null
this.pubDate = episode.pubDate
this.link = episode.link
this.author = episode.author
this.explicit = episode.explicit
this.duration = episode.duration
this.season = episode.season
this.episode = episode.episode
this.episodeType = episode.episodeType
this.libraryItemId = episode.libraryItemId
this.episodeId = episode.episodeId || null
this.trackIndex = episode.trackIndex || 0
this.fullPath = episode.fullPath
}
toJSON() {
return {
id: this.id,
title: this.title,
description: this.description,
enclosure: this.enclosure ? { ...this.enclosure } : null,
pubDate: this.pubDate,
link: this.link,
author: this.author,
explicit: this.explicit,
duration: this.duration,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
libraryItemId: this.libraryItemId,
episodeId: this.episodeId,
trackIndex: this.trackIndex,
fullPath: this.fullPath
}
}
setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, meta) {
const contentFileExtension = Path.extname(episode.audioFile.metadata.filename)
const contentUrl = `/feed/${slug}/item/${episode.id}/media${contentFileExtension}`
const media = libraryItem.media
const mediaMetadata = media.metadata
this.id = episode.id
this.title = episode.title
this.description = episode.description || ''
this.enclosure = {
url: `${contentUrl}`,
type: episode.audioTrack.mimeType,
size: episode.size
}
this.pubDate = episode.pubDate
this.link = meta.link
this.author = meta.author
this.explicit = mediaMetadata.explicit
this.duration = episode.duration
this.season = episode.season
this.episode = episode.episode
this.episodeType = episode.episodeType
this.libraryItemId = libraryItem.id
this.episodeId = episode.id
this.trackIndex = 0
this.fullPath = episode.audioFile.metadata.path
}
/**
*
* @param {import('../objects/LibraryItem')} libraryItem
* @param {string} serverAddress
* @param {string} slug
* @param {import('../objects/files/AudioTrack')} audioTrack
* @param {Object} meta
* @param {boolean} useChapterTitles
* @param {string} [pubDateOverride] Used for series & collections to ensure correct episode order
*/
setFromAudiobookTrack(libraryItem, serverAddress, slug, audioTrack, meta, useChapterTitles, pubDateOverride = null) {
// Example: <pubDate>Fri, 04 Feb 2015 00:00:00 GMT</pubDate>
let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order
let episodeId = uuidv4()
// e.g. Track 1 will have a pub date before Track 2
const audiobookPubDate = pubDateOverride || date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
const contentFileExtension = Path.extname(audioTrack.metadata.filename)
const contentUrl = `/feed/${slug}/item/${episodeId}/media${contentFileExtension}`
const media = libraryItem.media
const mediaMetadata = media.metadata
let title = audioTrack.title
if (libraryItem.media.tracks.length == 1) {
// If audiobook is a single file, use book title instead of chapter/file title
title = libraryItem.media.metadata.title
} else {
if (useChapterTitles) {
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
const matchingChapter = libraryItem.media.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1)
if (matchingChapter?.title) title = matchingChapter.title
}
}
this.id = episodeId
this.title = title
this.description = mediaMetadata.description || ''
this.enclosure = {
url: `${contentUrl}`,
type: audioTrack.mimeType,
size: audioTrack.metadata.size
}
this.pubDate = audiobookPubDate
this.link = meta.link
this.author = meta.author
this.explicit = mediaMetadata.explicit
this.duration = audioTrack.duration
this.libraryItemId = libraryItem.id
this.episodeId = null
this.trackIndex = audioTrack.index
this.fullPath = audioTrack.metadata.path
}
getRSSData(hostPrefix) {
return {
title: this.title,
description: this.description || '',
url: `${hostPrefix}${this.link}`,
guid: `${hostPrefix}${this.enclosure.url}`,
author: this.author,
date: this.pubDate,
enclosure: {
url: `${hostPrefix}${this.enclosure.url}`,
type: this.enclosure.type,
size: this.enclosure.size
},
custom_elements: [
{ 'itunes:author': this.author },
{ 'itunes:duration': secondsToTimestamp(this.duration) },
{ 'itunes:summary': this.description || '' },
{
'itunes:explicit': !!this.explicit
},
{ 'itunes:episodeType': this.episodeType },
{ 'itunes:season': this.season },
{ 'itunes:episode': this.episode }
]
}
}
}
module.exports = FeedEpisode

View File

@ -1,100 +0,0 @@
class FeedMeta {
constructor(meta) {
this.title = null
this.description = null
this.author = null
this.imageUrl = null
this.feedUrl = null
this.link = null
this.explicit = null
this.type = null
this.language = null
this.preventIndexing = null
this.ownerName = null
this.ownerEmail = null
if (meta) {
this.construct(meta)
}
}
construct(meta) {
this.title = meta.title
this.description = meta.description
this.author = meta.author
this.imageUrl = meta.imageUrl
this.feedUrl = meta.feedUrl
this.link = meta.link
this.explicit = meta.explicit
this.type = meta.type
this.language = meta.language
this.preventIndexing = meta.preventIndexing
this.ownerName = meta.ownerName
this.ownerEmail = meta.ownerEmail
}
toJSON() {
return {
title: this.title,
description: this.description,
author: this.author,
imageUrl: this.imageUrl,
feedUrl: this.feedUrl,
link: this.link,
explicit: this.explicit,
type: this.type,
language: this.language,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
}
}
toJSONMinified() {
return {
title: this.title,
description: this.description,
preventIndexing: this.preventIndexing,
ownerName: this.ownerName,
ownerEmail: this.ownerEmail
}
}
getRSSData(hostPrefix) {
const blockTags = [{ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }]
return {
title: this.title,
description: this.description || '',
generator: 'Audiobookshelf',
feed_url: `${hostPrefix}${this.feedUrl}`,
site_url: `${hostPrefix}${this.link}`,
image_url: `${hostPrefix}${this.imageUrl}`,
custom_namespaces: {
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
psc: 'http://podlove.org/simple-chapters',
podcast: 'https://podcastindex.org/namespace/1.0',
googleplay: 'http://www.google.com/schemas/play-podcasts/1.0'
},
custom_elements: [
{ language: this.language || 'en' },
{ author: this.author || 'advplyr' },
{ 'itunes:author': this.author || 'advplyr' },
{ 'itunes:summary': this.description || '' },
{ 'itunes:type': this.type },
{
'itunes:image': {
_attr: {
href: `${hostPrefix}${this.imageUrl}`
}
}
},
{
'itunes:owner': [{ 'itunes:name': this.ownerName || this.author || '' }, { 'itunes:email': this.ownerEmail || '' }]
},
{ 'itunes:explicit': !!this.explicit },
...(this.preventIndexing ? blockTags : [])
]
}
}
}
module.exports = FeedMeta

View File

@ -10,6 +10,7 @@ const fs = require('../libs/fsExtra')
const date = require('../libs/dateAndTime') const date = require('../libs/dateAndTime')
const CacheManager = require('../managers/CacheManager') const CacheManager = require('../managers/CacheManager')
const RssFeedManager = require('../managers/RssFeedManager')
const LibraryController = require('../controllers/LibraryController') const LibraryController = require('../controllers/LibraryController')
const UserController = require('../controllers/UserController') const UserController = require('../controllers/UserController')
@ -49,8 +50,6 @@ class ApiRouter {
this.podcastManager = Server.podcastManager this.podcastManager = Server.podcastManager
/** @type {import('../managers/AudioMetadataManager')} */ /** @type {import('../managers/AudioMetadataManager')} */
this.audioMetadataManager = Server.audioMetadataManager this.audioMetadataManager = Server.audioMetadataManager
/** @type {import('../managers/RssFeedManager')} */
this.rssFeedManager = Server.rssFeedManager
/** @type {import('../managers/CronManager')} */ /** @type {import('../managers/CronManager')} */
this.cronManager = Server.cronManager this.cronManager = Server.cronManager
/** @type {import('../managers/EmailManager')} */ /** @type {import('../managers/EmailManager')} */
@ -394,7 +393,7 @@ class ApiRouter {
} }
// Close rss feed - remove from db and emit socket event // Close rss feed - remove from db and emit socket event
await this.rssFeedManager.closeFeedForEntityId(libraryItemId) await RssFeedManager.closeFeedForEntityId(libraryItemId)
// purge cover cache // purge cover cache
await CacheManager.purgeCoverCache(libraryItemId) await CacheManager.purgeCoverCache(libraryItemId)
@ -493,7 +492,7 @@ class ApiRouter {
* @param {import('../models/Series')} series * @param {import('../models/Series')} series
*/ */
async removeEmptySeries(series) { async removeEmptySeries(series) {
await this.rssFeedManager.closeFeedForEntityId(series.id) await RssFeedManager.closeFeedForEntityId(series.id)
Logger.info(`[ApiRouter] Series "${series.name}" is now empty. Removing series`) Logger.info(`[ApiRouter] Series "${series.name}" is now empty. Removing series`)
// Remove series from library filter data // Remove series from library filter data

View File

@ -6,21 +6,24 @@ const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index')
const parseNameString = require('../utils/parsers/parseNameString') const parseNameString = require('../utils/parsers/parseNameString')
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const globals = require('../utils/globals') const globals = require('../utils/globals')
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
const AudioFileScanner = require('./AudioFileScanner') const AudioFileScanner = require('./AudioFileScanner')
const Database = require('../Database') const Database = require('../Database')
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
const AudioFile = require('../objects/files/AudioFile')
const CoverManager = require('../managers/CoverManager')
const LibraryFile = require('../objects/files/LibraryFile')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const fsExtra = require('../libs/fsExtra')
const BookFinder = require('../finders/BookFinder') const BookFinder = require('../finders/BookFinder')
const fsExtra = require('../libs/fsExtra')
const EBookFile = require('../objects/files/EBookFile')
const AudioFile = require('../objects/files/AudioFile')
const LibraryFile = require('../objects/files/LibraryFile')
const RssFeedManager = require('../managers/RssFeedManager')
const CoverManager = require('../managers/CoverManager')
const LibraryScan = require('./LibraryScan') const LibraryScan = require('./LibraryScan')
const OpfFileScanner = require('./OpfFileScanner') const OpfFileScanner = require('./OpfFileScanner')
const NfoFileScanner = require('./NfoFileScanner') const NfoFileScanner = require('./NfoFileScanner')
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner') const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
const EBookFile = require('../objects/files/EBookFile')
/** /**
* Metadata for books pulled from files * Metadata for books pulled from files
@ -941,6 +944,9 @@ class BookScanner {
id: bookSeriesToRemove id: bookSeriesToRemove
} }
}) })
// Close any open feeds for series
await RssFeedManager.closeFeedsForEntityIds(bookSeriesToRemove)
bookSeriesToRemove.forEach((seriesId) => { bookSeriesToRemove.forEach((seriesId) => {
Database.removeSeriesFromFilterData(libraryId, seriesId) Database.removeSeriesFromFilterData(libraryId, seriesId)
SocketAuthority.emitter('series_removed', { id: seriesId, libraryId }) SocketAuthority.emitter('series_removed', { id: seriesId, libraryId })

View File

@ -54,7 +54,7 @@ module.exports = {
items: libraryItems.map((li) => { items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.mediaItemShare) { if (li.mediaItemShare) {
oldLibraryItem.mediaItemShare = li.mediaItemShare oldLibraryItem.mediaItemShare = li.mediaItemShare
@ -91,7 +91,7 @@ module.exports = {
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.size && !oldLibraryItem.media.size) { if (li.size && !oldLibraryItem.media.size) {
oldLibraryItem.media.size = li.size oldLibraryItem.media.size = li.size
@ -109,7 +109,7 @@ module.exports = {
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.size && !oldLibraryItem.media.size) { if (li.size && !oldLibraryItem.media.size) {
oldLibraryItem.media.size = li.size oldLibraryItem.media.size = li.size
@ -138,7 +138,7 @@ module.exports = {
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.series) { if (li.series) {
oldLibraryItem.media.metadata.series = li.series oldLibraryItem.media.metadata.series = li.series
@ -168,7 +168,7 @@ module.exports = {
items: libraryItems.map((li) => { items: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.mediaItemShare) { if (li.mediaItemShare) {
oldLibraryItem.mediaItemShare = li.mediaItemShare oldLibraryItem.mediaItemShare = li.mediaItemShare
@ -279,7 +279,7 @@ module.exports = {
const oldSeries = s.toOldJSON() const oldSeries = s.toOldJSON()
if (s.feeds?.length) { if (s.feeds?.length) {
oldSeries.rssFeed = Database.feedModel.getOldFeed(s.feeds[0]).toJSONMinified() oldSeries.rssFeed = s.feeds[0].toOldJSONMinified()
} }
// TODO: Sort books by sequence in query // TODO: Sort books by sequence in query
@ -375,7 +375,7 @@ module.exports = {
libraryItems: libraryItems.map((li) => { libraryItems: libraryItems.map((li) => {
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified()
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = Database.feedModel.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified()
} }
if (li.mediaItemShare) { if (li.mediaItemShare) {
oldLibraryItem.mediaItemShare = li.mediaItemShare oldLibraryItem.mediaItemShare = li.mediaItemShare

View File

@ -615,8 +615,8 @@ module.exports = {
} }
} }
if (libraryItem.feeds?.length) { if (bookExpanded.libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0] libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0]
} }
if (includeMediaItemShare) { if (includeMediaItemShare) {
@ -766,8 +766,8 @@ module.exports = {
name: s.name, name: s.name,
sequence: s.bookSeries[bookIndex].sequence sequence: s.bookSeries[bookIndex].sequence
} }
if (libraryItem.feeds?.length) { if (s.bookSeries[bookIndex].book.libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0] libraryItem.rssFeed = s.bookSeries[bookIndex].book.libraryItem.feeds[0]
} }
libraryItem.media = book libraryItem.media = book
return libraryItem return libraryItem
@ -900,8 +900,8 @@ module.exports = {
delete book.libraryItem delete book.libraryItem
libraryItem.media = book libraryItem.media = book
if (libraryItem.feeds?.length) { if (bookExpanded.libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0] libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0]
} }
return libraryItem return libraryItem

View File

@ -180,8 +180,8 @@ module.exports = {
delete podcast.libraryItem delete podcast.libraryItem
if (libraryItem.feeds?.length) { if (podcastExpanded.libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0] libraryItem.rssFeed = podcastExpanded.libraryItem.feeds[0]
} }
if (podcast.numEpisodesIncomplete) { if (podcast.numEpisodesIncomplete) {
libraryItem.numEpisodesIncomplete = podcast.numEpisodesIncomplete libraryItem.numEpisodesIncomplete = podcast.numEpisodesIncomplete

View File

@ -182,7 +182,7 @@ module.exports = {
} }
if (s.feeds?.length) { if (s.feeds?.length) {
oldSeries.rssFeed = Database.feedModel.getOldFeed(s.feeds[0]).toJSONMinified() oldSeries.rssFeed = s.feeds[0].toOldJSONMinified()
} }
// TODO: Sort books by sequence in query // TODO: Sort books by sequence in query

View File

@ -6,7 +6,6 @@ const Database = require('../../../server/Database')
const ApiRouter = require('../../../server/routers/ApiRouter') const ApiRouter = require('../../../server/routers/ApiRouter')
const LibraryItemController = require('../../../server/controllers/LibraryItemController') const LibraryItemController = require('../../../server/controllers/LibraryItemController')
const ApiCacheManager = require('../../../server/managers/ApiCacheManager') const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
const RssFeedManager = require('../../../server/managers/RssFeedManager')
const Logger = require('../../../server/Logger') const Logger = require('../../../server/Logger')
describe('LibraryItemController', () => { describe('LibraryItemController', () => {
@ -20,8 +19,7 @@ describe('LibraryItemController', () => {
await Database.buildModels() await Database.buildModels()
apiRouter = new ApiRouter({ apiRouter = new ApiRouter({
apiCacheManager: new ApiCacheManager(), apiCacheManager: new ApiCacheManager()
rssFeedManager: new RssFeedManager()
}) })
sinon.stub(Logger, 'info') sinon.stub(Logger, 'info')