diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js index 2445eb50..3e35c08b 100644 --- a/server/controllers/CollectionController.js +++ b/server/controllers/CollectionController.js @@ -116,6 +116,7 @@ class CollectionController { } // If books array is passed in then update order in collection + let collectionBooksUpdated = false if (req.body.books?.length) { const collectionBooks = await req.collection.getCollectionBooks({ include: { @@ -134,9 +135,15 @@ class CollectionController { await collectionBooks[i].update({ 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() diff --git a/server/controllers/RSSFeedController.js b/server/controllers/RSSFeedController.js index 3cd7736b..e1c9c514 100644 --- a/server/controllers/RSSFeedController.js +++ b/server/controllers/RSSFeedController.js @@ -90,9 +90,6 @@ class RSSFeedController { async openRSSFeedForCollection(req, res) { const reqBody = req.body || {} - const collection = await Database.collectionModel.findByPk(req.params.collectionId) - if (!collection) return res.sendStatus(404) - // Check request body options exist if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') { Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) @@ -105,7 +102,8 @@ class RSSFeedController { return res.status(400).send('Slug already in use') } - collection.books = await collection.getBooksExpandedWithLibraryItem() + const collection = await Database.collectionModel.getExpandedById(req.params.collectionId) + if (!collection) return res.sendStatus(404) // Check collection has audio tracks if (!collection.books.some((book) => book.includedAudioFiles.length)) { @@ -135,9 +133,6 @@ class RSSFeedController { async openRSSFeedForSeries(req, res) { const reqBody = req.body || {} - const series = await Database.seriesModel.findByPk(req.params.seriesId) - if (!series) return res.sendStatus(404) - // Check request body options exist if (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') { Logger.error(`[RSSFeedController] Invalid request body to open RSS feed`) @@ -150,7 +145,8 @@ class RSSFeedController { return res.status(400).send('Slug already in use') } - series.books = await series.getBooksExpandedWithLibraryItem() + const series = await Database.seriesModel.getExpandedById(req.params.seriesId) + if (!series) return res.sendStatus(404) // Check series has audio tracks if (!series.books.some((book) => book.includedAudioFiles.length)) { diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index e92c3259..335df110 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -6,7 +6,6 @@ const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') const fs = require('../libs/fsExtra') -const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters') class RssFeedManager { constructor() {} @@ -69,6 +68,69 @@ class RssFeedManager { return Database.feedModel.findOneOld({ slug }) } + /** + * Feed requires update if the entity (or child entities) has been updated since the feed was last updated + * + * @param {import('../models/Feed')} feed + * @returns {Promise} + */ + async checkFeedRequiresUpdate(feed) { + 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') + } + } + /** * GET: /feed/:slug * @@ -76,88 +138,23 @@ class RssFeedManager { * @param {Response} 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) { Logger.warn(`[RssFeedManager] Feed not found ${req.params.slug}`) res.sendStatus(404) return } - // Check if feed needs to be updated - if (feed.entityType === 'libraryItem') { - const libraryItem = await Database.libraryItemModel.getOldById(feed.entityId) - - let mostRecentlyUpdatedAt = libraryItem.updatedAt - if (libraryItem.isPodcast) { - 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 feedRequiresUpdate = await this.checkFeedRequiresUpdate(feed) + if (feedRequiresUpdate) { + Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`) + feed = await feed.updateFeedForEntity() + } else { + feed.feedEpisodes = await feed.getFeedEpisodes() } const xml = feed.buildXml(req.originalHostPrefix) diff --git a/server/models/Collection.js b/server/models/Collection.js index 0fb622dd..558ae843 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -1,7 +1,6 @@ const { DataTypes, Model, Sequelize } = require('sequelize') const oldCollection = require('../objects/Collection') -const Logger = require('../Logger') class Collection extends Model { constructor(values, options) { @@ -121,6 +120,39 @@ class Collection extends Model { .filter((c) => c) } + /** + * + * @param {string} collectionId + * @returns {Promise} + */ + 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 * @param {Collection} collectionExpanded diff --git a/server/models/Feed.js b/server/models/Feed.js index 28484cfd..f6ee4e3c 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -2,6 +2,9 @@ const Path = require('path') const { DataTypes, Model } = require('sequelize') const oldFeed = require('../objects/Feed') const areEquivalent = require('../utils/areEquivalent') +const Logger = require('../Logger') + +const RSS = require('../libs/rss') /** * @typedef FeedOptions @@ -66,6 +69,8 @@ class Feed extends Model { /** @type {Date} */ this.updatedAt + // Expanded properties + /** @type {import('./FeedEpisode')[]} - only set if expanded */ this.feedEpisodes } @@ -272,11 +277,11 @@ class Feed extends Model { * @param {import('./LibraryItem').LibraryItemExpanded} libraryItem * @param {string} slug * @param {string} serverAddress - * @param {FeedOptions} feedOptions + * @param {FeedOptions} [feedOptions=null] * - * @returns {Promise} + * @returns {Feed} */ - static async createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) { + static getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions = null) { const media = libraryItem.media let entityUpdatedAt = libraryItem.updatedAt @@ -302,14 +307,33 @@ class Feed extends Model { author: libraryItem.mediaType === 'podcast' ? media.author : media.authorName, podcastType: libraryItem.mediaType === 'podcast' ? media.podcastType : 'serial', language: media.language, - preventIndexing: feedOptions.preventIndexing, - ownerName: feedOptions.ownerName, - ownerEmail: feedOptions.ownerEmail, explicit: media.explicit, coverPath: media.coverPath, 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} + */ + static async createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) { + const feedObj = this.getFeedObjForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) + /** @type {typeof import('./FeedEpisode')} */ const feedEpisodeModel = this.sequelize.models.feedEpisode @@ -339,11 +363,11 @@ class Feed extends Model { * @param {import('./Collection')} collectionExpanded * @param {string} slug * @param {string} serverAddress - * @param {FeedOptions} feedOptions + * @param {FeedOptions} [feedOptions=null] * - * @returns {Promise} + * @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }} */ - static async createFeedForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions) { + static getFeedObjForCollection(userId, collectionExpanded, slug, serverAddress, feedOptions = null) { const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length) const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => { @@ -374,14 +398,36 @@ class Feed extends Model { description: collectionExpanded.description || '', author, podcastType: 'serial', - preventIndexing: feedOptions.preventIndexing, - ownerName: feedOptions.ownerName, - ownerEmail: feedOptions.ownerEmail, 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} + */ + 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 @@ -406,11 +452,11 @@ class Feed extends Model { * @param {import('./Series')} seriesExpanded * @param {string} slug * @param {string} serverAddress - * @param {FeedOptions} feedOptions + * @param {FeedOptions} [feedOptions=null] * - * @returns {Promise} + * @returns {{ feedObj: Feed, booksWithTracks: import('./Book').BookExpandedWithLibraryItem[] }} */ - static async createFeedForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions) { + 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 @@ -440,14 +486,36 @@ class Feed extends Model { description: seriesExpanded.description || '', author, podcastType: 'serial', - preventIndexing: feedOptions.preventIndexing, - ownerName: feedOptions.ownerName, - ownerEmail: feedOptions.ownerEmail, 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} + */ + 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 @@ -460,7 +528,7 @@ class Feed extends Model { return feed } catch (error) { - Logger.error(`[Feed] Error creating feed for collection ${collectionExpanded.id}`, error) + Logger.error(`[Feed] Error creating feed for series ${seriesExpanded.id}`, error) await transaction.rollback() return null } @@ -580,12 +648,133 @@ class Feed extends Model { }) } + /** + * + * @returns {Promise} + */ + 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) { if (!this.entityType) return Promise.resolve(null) const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}` 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() + } + toOldJSON() { const episodes = this.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) return { diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js index 4979ae6d..b14f1b7b 100644 --- a/server/models/FeedEpisode.js +++ b/server/models/FeedEpisode.js @@ -3,6 +3,7 @@ const { DataTypes, Model } = require('sequelize') const uuidv4 = require('uuid').v4 const Logger = require('../Logger') const date = require('../libs/dateAndTime') +const { secondsToTimestamp } = require('../utils') class FeedEpisode extends Model { constructor(values, options) { @@ -13,6 +14,8 @@ class FeedEpisode extends Model { /** @type {string} */ this.title /** @type {string} */ + this.author + /** @type {string} */ this.description /** @type {string} */ this.siteURL @@ -301,6 +304,37 @@ class FeedEpisode extends Model { 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 diff --git a/server/models/Series.js b/server/models/Series.js index 4fa6b403..c4bc1594 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -54,6 +54,18 @@ class Series extends Model { }) } + /** + * + * @param {string} seriesId + * @returns {Promise} + */ + static async getExpandedById(seriesId) { + const series = await this.findByPk(seriesId) + if (!series) return null + series.books = await series.getBooksExpandedWithLibraryItem() + return series + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/objects/Feed.js b/server/objects/Feed.js index 668544d9..4da7a995 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -1,15 +1,6 @@ -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 @@ -82,165 +73,5 @@ class Feed { 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() - } - - 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() - } - - 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 diff --git a/server/objects/FeedEpisode.js b/server/objects/FeedEpisode.js index 13d590ff..4438eb52 100644 --- a/server/objects/FeedEpisode.js +++ b/server/objects/FeedEpisode.js @@ -1,8 +1,3 @@ -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 @@ -68,114 +63,5 @@ class FeedEpisode { 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: Fri, 04 Feb 2015 00:00:00 GMT - 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 diff --git a/server/objects/FeedMeta.js b/server/objects/FeedMeta.js index e439fe8f..61fccaa3 100644 --- a/server/objects/FeedMeta.js +++ b/server/objects/FeedMeta.js @@ -59,42 +59,5 @@ class FeedMeta { 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