diff --git a/server/objects/Feed.js b/server/objects/Feed.js index 2fac5d91..35c09f74 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -1,7 +1,9 @@ const Path = require('path') -const uuidv4 = require("uuid").v4 +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({ @@ -46,7 +48,7 @@ class Feed { this.serverAddress = feed.serverAddress this.feedUrl = feed.feedUrl this.meta = new FeedMeta(feed.meta) - this.episodes = feed.episodes.map(ep => new FeedEpisode(ep)) + this.episodes = feed.episodes.map((ep) => new FeedEpisode(ep)) this.createdAt = feed.createdAt this.updatedAt = feed.updatedAt } @@ -62,7 +64,7 @@ class Feed { serverAddress: this.serverAddress, feedUrl: this.feedUrl, meta: this.meta.toJSON(), - episodes: this.episodes.map(ep => ep.toJSON()), + episodes: this.episodes.map((ep) => ep.toJSON()), createdAt: this.createdAt, updatedAt: this.updatedAt } @@ -74,20 +76,20 @@ class Feed { entityType: this.entityType, entityId: this.entityId, feedUrl: this.feedUrl, - meta: this.meta.toJSONMinified(), + meta: this.meta.toJSONMinified() } } getEpisodePath(id) { - var episode = this.episodes.find(ep => ep.id === 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 + * + * @param {import('../objects/LibraryItem')} libraryItem * @returns {boolean} */ checkUseChapterTitlesForEpisodes(libraryItem) { @@ -137,7 +139,8 @@ class Feed { this.meta.ownerEmail = ownerEmail this.episodes = [] - if (isPodcast) { // PODCAST EPISODES + if (isPodcast) { + // PODCAST EPISODES media.episodes.forEach((episode) => { if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt @@ -145,7 +148,8 @@ class Feed { feedEpisode.setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, this.meta) this.episodes.push(feedEpisode) }) - } else { // AUDIOBOOK EPISODES + } else { + // AUDIOBOOK EPISODES const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItem) media.tracks.forEach((audioTrack) => { const feedEpisode = new FeedEpisode() @@ -178,7 +182,8 @@ class Feed { this.meta.language = mediaMetadata.language this.episodes = [] - if (isPodcast) { // PODCAST EPISODES + if (isPodcast) { + // PODCAST EPISODES media.episodes.forEach((episode) => { if (episode.updatedAt > this.entityUpdatedAt) this.entityUpdatedAt = episode.updatedAt @@ -186,7 +191,8 @@ class Feed { feedEpisode.setFromPodcastEpisode(libraryItem, this.serverAddress, this.slug, episode, this.meta) this.episodes.push(feedEpisode) }) - } else { // AUDIOBOOK EPISODES + } else { + // AUDIOBOOK EPISODES const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItem) media.tracks.forEach((audioTrack) => { const feedEpisode = new FeedEpisode() @@ -202,8 +208,8 @@ class Feed { setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { const feedUrl = `${serverAddress}/feed/${slug}` - const itemsWithTracks = collectionExpanded.books.filter(libraryItem => libraryItem.media.tracks.length) - const firstItemWithCover = itemsWithTracks.find(item => item.media.coverPath) + const itemsWithTracks = collectionExpanded.books.filter((libraryItem) => libraryItem.media.tracks.length) + const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath) this.id = uuidv4() this.slug = slug @@ -224,20 +230,28 @@ class Feed { this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` this.meta.feedUrl = feedUrl this.meta.link = `${serverAddress}/collection/${collectionExpanded.id}` - this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit + 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() - feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, index) + + // 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) }) }) @@ -247,8 +261,8 @@ class Feed { } updateFromCollection(collectionExpanded) { - const itemsWithTracks = collectionExpanded.books.filter(libraryItem => libraryItem.media.tracks.length) - const firstItemWithCover = itemsWithTracks.find(item => item.media.coverPath) + 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?.coverPath || null @@ -259,17 +273,25 @@ class Feed { this.meta.description = collectionExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` - this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit + 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() - feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, index) + + // 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) }) }) @@ -281,12 +303,12 @@ class Feed { setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { const feedUrl = `${serverAddress}/feed/${slug}` - let itemsWithTracks = seriesExpanded.books.filter(libraryItem => libraryItem.media.tracks.length) + 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)) + itemsWithTracks = naturalSort(itemsWithTracks).asc((li) => li.media.metadata.getSeriesSequence(seriesExpanded.id)) const libraryId = itemsWithTracks[0].libraryId - const firstItemWithCover = itemsWithTracks.find(li => li.media.coverPath) + const firstItemWithCover = itemsWithTracks.find((li) => li.media.coverPath) this.id = uuidv4() this.slug = slug @@ -307,20 +329,28 @@ class Feed { this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` this.meta.feedUrl = feedUrl this.meta.link = `${serverAddress}/library/${libraryId}/series/${seriesExpanded.id}` - this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit + this.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() - feedEpisode.setFromAudiobookTrack(item, serverAddress, slug, audioTrack, this.meta, useChapterTitles, index) + + // 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) }) }) @@ -330,11 +360,11 @@ class Feed { } updateFromSeries(seriesExpanded) { - let itemsWithTracks = seriesExpanded.books.filter(libraryItem => libraryItem.media.tracks.length) + 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)) + itemsWithTracks = naturalSort(itemsWithTracks).asc((li) => li.media.metadata.getSeriesSequence(seriesExpanded.id)) - const firstItemWithCover = itemsWithTracks.find(item => item.media.coverPath) + const firstItemWithCover = itemsWithTracks.find((item) => item.media.coverPath) this.entityUpdatedAt = seriesExpanded.updatedAt this.coverPath = firstItemWithCover?.coverPath || null @@ -345,17 +375,25 @@ class Feed { this.meta.description = seriesExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` - this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit + 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() - feedEpisode.setFromAudiobookTrack(item, this.serverAddress, this.slug, audioTrack, this.meta, useChapterTitles, index) + + // 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) }) }) @@ -377,7 +415,7 @@ class Feed { getAuthorsStringFromLibraryItems(libraryItems) { let itemAuthors = [] - libraryItems.forEach((item) => itemAuthors.push(...item.media.metadata.authors.map(au => au.name))) + 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) { diff --git a/server/objects/FeedEpisode.js b/server/objects/FeedEpisode.js index 50e27cf6..6d9f36a0 100644 --- a/server/objects/FeedEpisode.js +++ b/server/objects/FeedEpisode.js @@ -1,5 +1,5 @@ const Path = require('path') -const uuidv4 = require("uuid").v4 +const uuidv4 = require('uuid').v4 const date = require('../libs/dateAndTime') const { secondsToTimestamp } = require('../utils/index') @@ -98,27 +98,22 @@ class FeedEpisode { } /** - * - * @param {import('../objects/LibraryItem')} libraryItem - * @param {string} serverAddress - * @param {string} slug - * @param {import('../objects/files/AudioTrack')} audioTrack - * @param {Object} meta - * @param {boolean} useChapterTitles - * @param {number} [additionalOffset] + * + * @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, additionalOffset = null) { + 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 timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order let episodeId = uuidv4() - // Additional offset can be used for collections/series - if (additionalOffset !== null && !isNaN(additionalOffset)) { - timeOffset += Number(additionalOffset) * 1000 - } - // e.g. Track 1 will have a pub date before Track 2 - const audiobookPubDate = date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]') + 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}` @@ -126,12 +121,13 @@ class FeedEpisode { 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 + 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) + const matchingChapter = libraryItem.media.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1) if (matchingChapter?.title) title = matchingChapter.title } } @@ -169,11 +165,11 @@ class FeedEpisode { { 'itunes:duration': secondsToTimestamp(this.duration) }, { 'itunes:summary': this.description || '' }, { - "itunes:explicit": !!this.explicit + 'itunes:explicit': !!this.explicit }, - { "itunes:episodeType": this.episodeType }, - { "itunes:season": this.season }, - { "itunes:episode": this.episode } + { 'itunes:episodeType': this.episodeType }, + { 'itunes:season': this.season }, + { 'itunes:episode': this.episode } ] } }