mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
Merge pull request #3724 from advplyr/feed_migration
Refactor Feed model to create new feed for collection
This commit is contained in:
commit
6cef1e3f12
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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')) {
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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)
|
||||||
|
@ -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({
|
||||||
|
attributes: ['id', 'entityId', 'entityType', 'title'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.libraryItemModel,
|
||||||
|
attributes: ['id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.collectionModel,
|
||||||
|
attributes: ['id']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.seriesModel,
|
||||||
|
attributes: ['id']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const feedIdsToRemove = []
|
||||||
for (const feed of feeds) {
|
for (const feed of feeds) {
|
||||||
// Remove invalid feeds
|
if (!feed.entity) {
|
||||||
if (!(await this.validateFeedEntity(feed))) {
|
Logger.error(`[RssFeedManager] Removing feed "${feed.title}". Entity not found`)
|
||||||
await Database.removeFeed(feed.id)
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleCloseFeed(feed) {
|
|
||||||
if (!feed) return
|
|
||||||
await Database.removeFeed(feed.id)
|
|
||||||
SocketAuthority.emitter('rss_feed_closed', feed.toJSONMinified())
|
|
||||||
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedUrl}"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
return feedExpanded
|
||||||
res.sendStatus(200)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close Feed and emit Socket event
|
||||||
|
*
|
||||||
|
* @param {import('../models/Feed')} feed
|
||||||
|
* @returns {Promise<boolean>} - true if feed was closed
|
||||||
|
*/
|
||||||
|
async handleCloseFeed(feed) {
|
||||||
|
if (!feed) return false
|
||||||
|
const wasRemoved = await Database.feedModel.removeById(feed.id)
|
||||||
|
SocketAuthority.emitter('rss_feed_closed', feed.toOldJSONMinified())
|
||||||
|
Logger.info(`[RssFeedManager] Closed RSS feed "${feed.feedURL}"`)
|
||||||
|
return wasRemoved
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @returns {Promise<import('../models/Feed').FeedExpanded[]>}
|
||||||
|
*/
|
||||||
|
getFeeds() {
|
||||||
|
return Database.feedModel.findAll({
|
||||||
|
include: {
|
||||||
|
model: Database.feedEpisodeModel
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = RssFeedManager
|
module.exports = new RssFeedManager()
|
||||||
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
where: {
|
||||||
if (feedExpanded.podcastType === 'episodic') {
|
id: feedId
|
||||||
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: {
|
|
||||||
id: feedId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
})) > 0
|
||||||
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 {
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
@ -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 })
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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')
|
||||||
|
Loading…
Reference in New Issue
Block a user