2024-12-14 23:55:56 +01:00
|
|
|
const Path = require('path')
|
2023-07-05 01:14:44 +02:00
|
|
|
const { DataTypes, Model } = require('sequelize')
|
2024-12-14 23:55:56 +01:00
|
|
|
const uuidv4 = require('uuid').v4
|
|
|
|
const Logger = require('../Logger')
|
|
|
|
const date = require('../libs/dateAndTime')
|
2024-12-15 23:56:59 +01:00
|
|
|
const { secondsToTimestamp } = require('../utils')
|
2023-07-05 01:14:44 +02:00
|
|
|
|
2023-08-16 01:03:43 +02:00
|
|
|
class FeedEpisode extends Model {
|
|
|
|
constructor(values, options) {
|
|
|
|
super(values, options)
|
|
|
|
|
|
|
|
/** @type {UUIDV4} */
|
|
|
|
this.id
|
|
|
|
/** @type {string} */
|
|
|
|
this.title
|
|
|
|
/** @type {string} */
|
2024-12-15 23:56:59 +01:00
|
|
|
this.author
|
|
|
|
/** @type {string} */
|
2023-08-16 01:03:43 +02:00
|
|
|
this.description
|
|
|
|
/** @type {string} */
|
|
|
|
this.siteURL
|
|
|
|
/** @type {string} */
|
|
|
|
this.enclosureURL
|
|
|
|
/** @type {string} */
|
|
|
|
this.enclosureType
|
|
|
|
/** @type {BigInt} */
|
|
|
|
this.enclosureSize
|
|
|
|
/** @type {string} */
|
|
|
|
this.pubDate
|
|
|
|
/** @type {string} */
|
|
|
|
this.season
|
|
|
|
/** @type {string} */
|
|
|
|
this.episode
|
|
|
|
/** @type {string} */
|
|
|
|
this.episodeType
|
|
|
|
/** @type {number} */
|
|
|
|
this.duration
|
|
|
|
/** @type {string} */
|
|
|
|
this.filePath
|
|
|
|
/** @type {boolean} */
|
|
|
|
this.explicit
|
|
|
|
/** @type {UUIDV4} */
|
|
|
|
this.feedId
|
|
|
|
/** @type {Date} */
|
|
|
|
this.createdAt
|
|
|
|
/** @type {Date} */
|
|
|
|
this.updatedAt
|
|
|
|
}
|
|
|
|
|
2024-12-14 23:55:56 +01:00
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
|
|
|
|
* @param {import('./Feed')} feed
|
|
|
|
* @param {string} slug
|
|
|
|
* @param {import('./PodcastEpisode')} episode
|
|
|
|
*/
|
|
|
|
static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) {
|
2024-12-16 00:54:36 +01:00
|
|
|
const episodeId = uuidv4()
|
2024-12-14 23:55:56 +01:00
|
|
|
return {
|
2024-12-16 00:54:36 +01:00
|
|
|
id: episodeId,
|
2024-12-14 23:55:56 +01:00
|
|
|
title: episode.title,
|
|
|
|
author: feed.author,
|
|
|
|
description: episode.description,
|
|
|
|
siteURL: feed.siteURL,
|
2024-12-16 00:54:36 +01:00
|
|
|
enclosureURL: `/feed/${slug}/item/${episodeId}/media${Path.extname(episode.audioFile.metadata.filename)}`,
|
2024-12-14 23:55:56 +01:00
|
|
|
enclosureType: episode.audioFile.mimeType,
|
|
|
|
enclosureSize: episode.audioFile.metadata.size,
|
|
|
|
pubDate: episode.pubDate,
|
|
|
|
season: episode.season,
|
|
|
|
episode: episode.episode,
|
|
|
|
episodeType: episode.episodeType,
|
|
|
|
duration: episode.audioFile.duration,
|
|
|
|
filePath: episode.audioFile.metadata.path,
|
|
|
|
explicit: libraryItemExpanded.media.explicit,
|
|
|
|
feedId: feed.id
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
|
|
|
|
* @param {import('./Feed')} feed
|
|
|
|
* @param {string} slug
|
|
|
|
* @param {import('sequelize').Transaction} transaction
|
|
|
|
* @returns {Promise<FeedEpisode[]>}
|
|
|
|
*/
|
|
|
|
static async createFromPodcastEpisodes(libraryItemExpanded, feed, slug, transaction) {
|
|
|
|
const feedEpisodeObjs = []
|
|
|
|
|
|
|
|
// Sort podcastEpisodes by pubDate. episodic is newest to oldest. serial is oldest to newest.
|
|
|
|
if (feed.podcastType === 'episodic') {
|
|
|
|
libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
|
|
|
|
} else {
|
|
|
|
libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate))
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const episode of libraryItemExpanded.media.podcastEpisodes) {
|
|
|
|
feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode))
|
|
|
|
}
|
|
|
|
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
|
|
|
|
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names
|
|
|
|
*
|
2024-12-15 17:53:31 +01:00
|
|
|
* @param {import('./Book')} book
|
2024-12-14 23:55:56 +01:00
|
|
|
* @returns {boolean}
|
|
|
|
*/
|
2024-12-15 17:53:31 +01:00
|
|
|
static checkUseChapterTitlesForEpisodes(book) {
|
|
|
|
const tracks = book.trackList || []
|
|
|
|
const chapters = book.chapters || []
|
2024-12-14 23:55:56 +01:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
2024-12-15 17:53:31 +01:00
|
|
|
* @param {import('./Book')} book
|
|
|
|
* @param {Date} pubDateStart
|
2024-12-14 23:55:56 +01:00
|
|
|
* @param {import('./Feed')} feed
|
|
|
|
* @param {string} slug
|
|
|
|
* @param {import('./Book').AudioFileObject} audioTrack
|
|
|
|
* @param {boolean} useChapterTitles
|
|
|
|
*/
|
2024-12-15 17:53:31 +01:00
|
|
|
static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles) {
|
2024-12-14 23:55:56 +01:00
|
|
|
// 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
|
2024-12-15 17:53:31 +01:00
|
|
|
const audiobookPubDate = date.format(new Date(pubDateStart.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
2024-12-14 23:55:56 +01:00
|
|
|
|
|
|
|
const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}`
|
|
|
|
|
|
|
|
let title = audioTrack.title
|
2024-12-15 17:53:31 +01:00
|
|
|
if (book.trackList.length == 1) {
|
2024-12-14 23:55:56 +01:00
|
|
|
// If audiobook is a single file, use book title instead of chapter/file title
|
2024-12-15 17:53:31 +01:00
|
|
|
title = book.title
|
2024-12-14 23:55:56 +01:00
|
|
|
} else {
|
|
|
|
if (useChapterTitles) {
|
|
|
|
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
|
2024-12-15 17:53:31 +01:00
|
|
|
const matchingChapter = book.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1)
|
2024-12-14 23:55:56 +01:00
|
|
|
if (matchingChapter?.title) title = matchingChapter.title
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
id: episodeId,
|
|
|
|
title,
|
|
|
|
author: feed.author,
|
2024-12-15 17:53:31 +01:00
|
|
|
description: book.description || '',
|
2024-12-14 23:55:56 +01:00
|
|
|
siteURL: feed.siteURL,
|
|
|
|
enclosureURL: contentUrl,
|
|
|
|
enclosureType: audioTrack.mimeType,
|
|
|
|
enclosureSize: audioTrack.metadata.size,
|
|
|
|
pubDate: audiobookPubDate,
|
|
|
|
duration: audioTrack.duration,
|
|
|
|
filePath: audioTrack.metadata.path,
|
2024-12-15 17:53:31 +01:00
|
|
|
explicit: book.explicit,
|
2024-12-14 23:55:56 +01:00
|
|
|
feedId: feed.id
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
|
|
|
|
* @param {import('./Feed')} feed
|
|
|
|
* @param {string} slug
|
|
|
|
* @param {import('sequelize').Transaction} transaction
|
|
|
|
* @returns {Promise<FeedEpisode[]>}
|
|
|
|
*/
|
|
|
|
static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) {
|
2024-12-15 17:53:31 +01:00
|
|
|
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded.media)
|
2024-12-14 23:55:56 +01:00
|
|
|
|
|
|
|
const feedEpisodeObjs = []
|
|
|
|
for (const track of libraryItemExpanded.media.trackList) {
|
2024-12-15 17:53:31 +01:00
|
|
|
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles))
|
|
|
|
}
|
|
|
|
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
|
|
|
|
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
2024-12-15 18:44:07 +01:00
|
|
|
* @param {import('./Book')[]} books
|
2024-12-15 17:53:31 +01:00
|
|
|
* @param {import('./Feed')} feed
|
|
|
|
* @param {string} slug
|
|
|
|
* @param {import('sequelize').Transaction} transaction
|
|
|
|
* @returns {Promise<FeedEpisode[]>}
|
|
|
|
*/
|
2024-12-15 18:44:07 +01:00
|
|
|
static async createFromBooks(books, feed, slug, transaction) {
|
|
|
|
const earliestLibraryItemCreatedAt = books.reduce((earliest, book) => {
|
2024-12-15 17:53:31 +01:00
|
|
|
return book.libraryItem.createdAt < earliest.libraryItem.createdAt ? book : earliest
|
|
|
|
}).libraryItem.createdAt
|
|
|
|
|
|
|
|
const feedEpisodeObjs = []
|
2024-12-15 18:44:07 +01:00
|
|
|
for (const book of books) {
|
2024-12-15 17:53:31 +01:00
|
|
|
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book)
|
|
|
|
for (const track of book.trackList) {
|
|
|
|
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles))
|
|
|
|
}
|
2024-12-14 23:55:56 +01:00
|
|
|
}
|
|
|
|
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
|
|
|
|
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
|
|
|
}
|
|
|
|
|
2023-08-16 01:03:43 +02:00
|
|
|
/**
|
|
|
|
* Initialize model
|
2024-05-29 00:24:02 +02:00
|
|
|
* @param {import('../Database').sequelize} sequelize
|
2023-08-16 01:03:43 +02:00
|
|
|
*/
|
|
|
|
static init(sequelize) {
|
2024-05-29 00:24:02 +02:00
|
|
|
super.init(
|
|
|
|
{
|
|
|
|
id: {
|
|
|
|
type: DataTypes.UUID,
|
|
|
|
defaultValue: DataTypes.UUIDV4,
|
|
|
|
primaryKey: true
|
|
|
|
},
|
|
|
|
title: DataTypes.STRING,
|
|
|
|
author: DataTypes.STRING,
|
|
|
|
description: DataTypes.TEXT,
|
|
|
|
siteURL: DataTypes.STRING,
|
|
|
|
enclosureURL: DataTypes.STRING,
|
|
|
|
enclosureType: DataTypes.STRING,
|
|
|
|
enclosureSize: DataTypes.BIGINT,
|
|
|
|
pubDate: DataTypes.STRING,
|
|
|
|
season: DataTypes.STRING,
|
|
|
|
episode: DataTypes.STRING,
|
|
|
|
episodeType: DataTypes.STRING,
|
|
|
|
duration: DataTypes.FLOAT,
|
|
|
|
filePath: DataTypes.STRING,
|
|
|
|
explicit: DataTypes.BOOLEAN
|
2023-08-16 01:03:43 +02:00
|
|
|
},
|
2024-05-29 00:24:02 +02:00
|
|
|
{
|
|
|
|
sequelize,
|
|
|
|
modelName: 'feedEpisode'
|
|
|
|
}
|
|
|
|
)
|
2023-07-05 01:14:44 +02:00
|
|
|
|
2023-08-16 01:03:43 +02:00
|
|
|
const { feed } = sequelize.models
|
2023-07-05 01:14:44 +02:00
|
|
|
|
2023-08-16 01:03:43 +02:00
|
|
|
feed.hasMany(FeedEpisode, {
|
|
|
|
onDelete: 'CASCADE'
|
|
|
|
})
|
|
|
|
FeedEpisode.belongsTo(feed)
|
|
|
|
}
|
2024-12-14 23:55:56 +01:00
|
|
|
|
|
|
|
getOldEpisode() {
|
|
|
|
const enclosure = {
|
|
|
|
url: this.enclosureURL,
|
|
|
|
size: this.enclosureSize,
|
|
|
|
type: this.enclosureType
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
id: this.id,
|
|
|
|
title: this.title,
|
|
|
|
description: this.description,
|
|
|
|
enclosure,
|
|
|
|
pubDate: this.pubDate,
|
|
|
|
link: this.siteURL,
|
|
|
|
author: this.author,
|
|
|
|
explicit: this.explicit,
|
|
|
|
duration: this.duration,
|
|
|
|
season: this.season,
|
|
|
|
episode: this.episode,
|
|
|
|
episodeType: this.episodeType,
|
|
|
|
fullPath: this.filePath
|
|
|
|
}
|
|
|
|
}
|
2024-12-15 23:56:59 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @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 }
|
|
|
|
]
|
|
|
|
}
|
|
|
|
}
|
2023-08-16 01:03:43 +02:00
|
|
|
}
|
2023-07-05 01:14:44 +02:00
|
|
|
|
2024-05-29 00:24:02 +02:00
|
|
|
module.exports = FeedEpisode
|