Migrate Feed updating and build xml to new model

This commit is contained in:
advplyr 2024-12-15 16:56:59 -06:00
parent 369c05936b
commit f8fbd3ac8c
10 changed files with 373 additions and 426 deletions

View file

@ -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

View file

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

View file

@ -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