mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
Migrate Feed updating and build xml to new model
This commit is contained in:
parent
369c05936b
commit
f8fbd3ac8c
@ -116,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: {
|
||||||
@ -134,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()
|
||||||
|
@ -90,9 +90,6 @@ class RSSFeedController {
|
|||||||
async openRSSFeedForCollection(req, res) {
|
async openRSSFeedForCollection(req, res) {
|
||||||
const reqBody = 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 (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
|
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`)
|
||||||
@ -105,7 +102,8 @@ class RSSFeedController {
|
|||||||
return res.status(400).send('Slug already in use')
|
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
|
// Check collection has audio tracks
|
||||||
if (!collection.books.some((book) => book.includedAudioFiles.length)) {
|
if (!collection.books.some((book) => book.includedAudioFiles.length)) {
|
||||||
@ -135,9 +133,6 @@ class RSSFeedController {
|
|||||||
async openRSSFeedForSeries(req, res) {
|
async openRSSFeedForSeries(req, res) {
|
||||||
const reqBody = 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 (!reqBody.serverAddress || !reqBody.slug || typeof reqBody.serverAddress !== 'string' || typeof reqBody.slug !== 'string') {
|
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`)
|
||||||
@ -150,7 +145,8 @@ class RSSFeedController {
|
|||||||
return res.status(400).send('Slug already in use')
|
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
|
// Check series has audio tracks
|
||||||
if (!series.books.some((book) => book.includedAudioFiles.length)) {
|
if (!series.books.some((book) => book.includedAudioFiles.length)) {
|
||||||
|
@ -6,7 +6,6 @@ const SocketAuthority = require('../SocketAuthority')
|
|||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
|
||||||
|
|
||||||
class RssFeedManager {
|
class RssFeedManager {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
@ -69,6 +68,69 @@ class RssFeedManager {
|
|||||||
return Database.feedModel.findOneOld({ slug })
|
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<boolean>}
|
||||||
|
*/
|
||||||
|
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
|
* GET: /feed/:slug
|
||||||
*
|
*
|
||||||
@ -76,88 +138,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)
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
const { DataTypes, Model, Sequelize } = require('sequelize')
|
const { DataTypes, Model, Sequelize } = require('sequelize')
|
||||||
|
|
||||||
const oldCollection = require('../objects/Collection')
|
const oldCollection = require('../objects/Collection')
|
||||||
const Logger = require('../Logger')
|
|
||||||
|
|
||||||
class Collection extends Model {
|
class Collection extends Model {
|
||||||
constructor(values, options) {
|
constructor(values, options) {
|
||||||
@ -121,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
|
||||||
|
@ -2,6 +2,9 @@ const Path = require('path')
|
|||||||
const { DataTypes, Model } = require('sequelize')
|
const { DataTypes, Model } = require('sequelize')
|
||||||
const oldFeed = require('../objects/Feed')
|
const oldFeed = require('../objects/Feed')
|
||||||
const areEquivalent = require('../utils/areEquivalent')
|
const areEquivalent = require('../utils/areEquivalent')
|
||||||
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
|
const RSS = require('../libs/rss')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef FeedOptions
|
* @typedef FeedOptions
|
||||||
@ -66,6 +69,8 @@ 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
|
||||||
}
|
}
|
||||||
@ -272,11 +277,11 @@ class Feed extends Model {
|
|||||||
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
|
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
|
||||||
* @param {string} slug
|
* @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
|
let entityUpdatedAt = libraryItem.updatedAt
|
||||||
@ -302,14 +307,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
|
||||||
|
|
||||||
@ -339,11 +363,11 @@ class Feed extends Model {
|
|||||||
* @param {import('./Collection')} collectionExpanded
|
* @param {import('./Collection')} collectionExpanded
|
||||||
* @param {string} slug
|
* @param {string} slug
|
||||||
* @param {string} serverAddress
|
* @param {string} serverAddress
|
||||||
* @param {FeedOptions} feedOptions
|
* @param {FeedOptions} [feedOptions=null]
|
||||||
*
|
*
|
||||||
* @returns {Promise<FeedExpanded>}
|
* @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 booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length)
|
||||||
|
|
||||||
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
|
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
|
||||||
@ -374,14 +398,36 @@ class Feed extends Model {
|
|||||||
description: collectionExpanded.description || '',
|
description: collectionExpanded.description || '',
|
||||||
author,
|
author,
|
||||||
podcastType: 'serial',
|
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
|
explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit
|
||||||
coverPath: firstBookWithCover?.coverPath || null,
|
coverPath: firstBookWithCover?.coverPath || null,
|
||||||
userId
|
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')} */
|
/** @type {typeof import('./FeedEpisode')} */
|
||||||
const feedEpisodeModel = this.sequelize.models.feedEpisode
|
const feedEpisodeModel = this.sequelize.models.feedEpisode
|
||||||
|
|
||||||
@ -406,11 +452,11 @@ class Feed extends Model {
|
|||||||
* @param {import('./Series')} seriesExpanded
|
* @param {import('./Series')} seriesExpanded
|
||||||
* @param {string} slug
|
* @param {string} slug
|
||||||
* @param {string} serverAddress
|
* @param {string} serverAddress
|
||||||
* @param {FeedOptions} feedOptions
|
* @param {FeedOptions} [feedOptions=null]
|
||||||
*
|
*
|
||||||
* @returns {Promise<FeedExpanded>}
|
* @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 booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length)
|
||||||
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
|
const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => {
|
||||||
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
|
return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent
|
||||||
@ -440,14 +486,36 @@ class Feed extends Model {
|
|||||||
description: seriesExpanded.description || '',
|
description: seriesExpanded.description || '',
|
||||||
author,
|
author,
|
||||||
podcastType: 'serial',
|
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
|
explicit: booksWithTracks.some((book) => book.explicit), // If any book is explicit, the feed is explicit
|
||||||
coverPath: firstBookWithCover?.coverPath || null,
|
coverPath: firstBookWithCover?.coverPath || null,
|
||||||
userId
|
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')} */
|
/** @type {typeof import('./FeedEpisode')} */
|
||||||
const feedEpisodeModel = this.sequelize.models.feedEpisode
|
const feedEpisodeModel = this.sequelize.models.feedEpisode
|
||||||
|
|
||||||
@ -460,7 +528,7 @@ class Feed extends Model {
|
|||||||
|
|
||||||
return feed
|
return feed
|
||||||
} catch (error) {
|
} 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()
|
await transaction.rollback()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@ -580,12 +648,133 @@ 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()
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@ -301,6 +304,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
|
||||||
|
@ -54,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
|
||||||
|
@ -1,15 +1,6 @@
|
|||||||
const Path = require('path')
|
|
||||||
const uuidv4 = require('uuid').v4
|
|
||||||
const FeedMeta = require('./FeedMeta')
|
const FeedMeta = require('./FeedMeta')
|
||||||
const FeedEpisode = require('./FeedEpisode')
|
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 {
|
class Feed {
|
||||||
constructor(feed) {
|
constructor(feed) {
|
||||||
this.id = null
|
this.id = null
|
||||||
@ -82,165 +73,5 @@ class Feed {
|
|||||||
if (!episode) return null
|
if (!episode) return null
|
||||||
return episode.fullPath
|
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
|
module.exports = Feed
|
||||||
|
@ -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 {
|
class FeedEpisode {
|
||||||
constructor(episode) {
|
constructor(episode) {
|
||||||
this.id = null
|
this.id = null
|
||||||
@ -68,114 +63,5 @@ class FeedEpisode {
|
|||||||
fullPath: this.fullPath
|
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
|
module.exports = FeedEpisode
|
||||||
|
@ -59,42 +59,5 @@ class FeedMeta {
|
|||||||
ownerEmail: this.ownerEmail
|
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
|
module.exports = FeedMeta
|
||||||
|
Loading…
Reference in New Issue
Block a user