mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-02-07 02:39:41 +00:00
Refactor Feed model to create new feed for library item
This commit is contained in:
parent
c4610e6102
commit
9bd1f9e3d5
7 changed files with 400 additions and 112 deletions
|
|
@ -106,6 +106,9 @@ class Book extends Model {
|
|||
this.updatedAt
|
||||
/** @type {Date} */
|
||||
this.createdAt
|
||||
|
||||
/** @type {import('./Author')[]} - optional if expanded */
|
||||
this.authors
|
||||
}
|
||||
|
||||
static getOldBook(libraryItemExpanded) {
|
||||
|
|
@ -320,6 +323,32 @@ class Book extends Model {
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Comma separated array of author names
|
||||
* Requires authors to be loaded
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
get authorName() {
|
||||
if (this.authors === undefined) {
|
||||
Logger.error(`[Book] authorName: Cannot get authorName because authors are not loaded`)
|
||||
return ''
|
||||
}
|
||||
return this.authors.map((au) => au.name).join(', ')
|
||||
}
|
||||
get includedAudioFiles() {
|
||||
return this.audioFiles.filter((af) => !af.exclude)
|
||||
}
|
||||
get trackList() {
|
||||
let startOffset = 0
|
||||
return this.includedAudioFiles.map((af) => {
|
||||
const track = structuredClone(af)
|
||||
track.startOffset = startOffset
|
||||
startOffset += track.duration
|
||||
return track
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Book
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
const Path = require('path')
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const oldFeed = require('../objects/Feed')
|
||||
const areEquivalent = require('../utils/areEquivalent')
|
||||
|
||||
/**
|
||||
* @typedef FeedOptions
|
||||
* @property {boolean} preventIndexing
|
||||
* @property {string} ownerName
|
||||
* @property {string} ownerEmail
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef FeedExpandedProperties
|
||||
* @property {import('./FeedEpisode')} feedEpisodes
|
||||
*
|
||||
* @typedef {Feed & FeedExpandedProperties} FeedExpanded
|
||||
*/
|
||||
|
||||
class Feed extends Model {
|
||||
constructor(values, options) {
|
||||
super(values, options)
|
||||
|
|
@ -50,6 +65,9 @@ class Feed extends Model {
|
|||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
|
||||
/** @type {import('./FeedEpisode')[]} - only set if expanded */
|
||||
this.feedEpisodes
|
||||
}
|
||||
|
||||
static async getOldFeeds() {
|
||||
|
|
@ -67,7 +85,15 @@ class Feed extends Model {
|
|||
* @returns {oldFeed}
|
||||
*/
|
||||
static getOldFeed(feedExpanded) {
|
||||
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
|
||||
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode()) || []
|
||||
|
||||
// Sort episodes by pubDate. Newest to oldest for episodic, oldest to newest for serial
|
||||
if (feedExpanded.podcastType === 'episodic') {
|
||||
episodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
|
||||
} else {
|
||||
episodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate))
|
||||
}
|
||||
|
||||
return new oldFeed({
|
||||
id: feedExpanded.id,
|
||||
slug: feedExpanded.slug,
|
||||
|
|
@ -92,7 +118,7 @@ class Feed extends Model {
|
|||
},
|
||||
serverAddress: feedExpanded.serverAddress,
|
||||
feedUrl: feedExpanded.feedURL,
|
||||
episodes: episodes || [],
|
||||
episodes,
|
||||
createdAt: feedExpanded.createdAt.valueOf(),
|
||||
updatedAt: feedExpanded.updatedAt.valueOf()
|
||||
})
|
||||
|
|
@ -250,10 +276,62 @@ class Feed extends Model {
|
|||
}
|
||||
}
|
||||
|
||||
getEntity(options) {
|
||||
if (!this.entityType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
|
||||
return this[mixinMethodName](options)
|
||||
/**
|
||||
*
|
||||
* @param {string} userId
|
||||
* @param {string} slug
|
||||
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItem
|
||||
* @param {string} serverAddress
|
||||
* @param {FeedOptions} feedOptions
|
||||
*
|
||||
* @returns {Promise<FeedExpanded>}
|
||||
*/
|
||||
static async createFeedForLibraryItem(userId, libraryItem, slug, serverAddress, feedOptions) {
|
||||
const media = libraryItem.media
|
||||
|
||||
const feedObj = {
|
||||
slug,
|
||||
entityType: 'libraryItem',
|
||||
entityId: libraryItem.id,
|
||||
entityUpdatedAt: libraryItem.updatedAt,
|
||||
serverAddress,
|
||||
feedURL: `/feed/${slug}`,
|
||||
imageURL: media.coverPath ? `/feed/${slug}/cover${Path.extname(media.coverPath)}` : `/Logo.png`,
|
||||
siteURL: `/item/${libraryItem.id}`,
|
||||
title: media.title,
|
||||
description: media.description,
|
||||
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
|
||||
}
|
||||
|
||||
/** @type {typeof import('./FeedEpisode')} */
|
||||
const feedEpisodeModel = this.sequelize.models.feedEpisode
|
||||
|
||||
const transaction = await this.sequelize.transaction()
|
||||
try {
|
||||
const feed = await this.create(feedObj, { transaction })
|
||||
|
||||
if (libraryItem.mediaType === 'podcast') {
|
||||
feed.feedEpisodes = await feedEpisodeModel.createFromPodcastEpisodes(libraryItem, feed, slug, transaction)
|
||||
} else {
|
||||
feed.feedEpisodes = await feedEpisodeModel.createFromAudiobookTracks(libraryItem, feed, slug, transaction)
|
||||
}
|
||||
|
||||
await transaction.commit()
|
||||
|
||||
return feed
|
||||
} catch (error) {
|
||||
Logger.error(`[Feed] Error creating feed for library item ${libraryItem.id}`, error)
|
||||
await transaction.rollback()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -369,6 +447,60 @@ class Feed extends Model {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
getEntity(options) {
|
||||
if (!this.entityType) return Promise.resolve(null)
|
||||
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.entityType)}`
|
||||
return this[mixinMethodName](options)
|
||||
}
|
||||
|
||||
toOldJSON() {
|
||||
const episodes = this.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
|
||||
return {
|
||||
id: this.id,
|
||||
slug: this.slug,
|
||||
userId: this.userId,
|
||||
entityType: this.entityType,
|
||||
entityId: this.entityId,
|
||||
entityUpdatedAt: this.entityUpdatedAt?.valueOf() || null,
|
||||
coverPath: this.coverPath || null,
|
||||
meta: {
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
author: this.author,
|
||||
imageUrl: this.imageURL,
|
||||
feedUrl: this.feedURL,
|
||||
link: this.siteURL,
|
||||
explicit: this.explicit,
|
||||
type: this.podcastType,
|
||||
language: this.language,
|
||||
preventIndexing: this.preventIndexing,
|
||||
ownerName: this.ownerName,
|
||||
ownerEmail: this.ownerEmail
|
||||
},
|
||||
serverAddress: this.serverAddress,
|
||||
feedUrl: this.feedURL,
|
||||
episodes: episodes || [],
|
||||
createdAt: this.createdAt.valueOf(),
|
||||
updatedAt: this.updatedAt.valueOf()
|
||||
}
|
||||
}
|
||||
|
||||
toOldJSONMinified() {
|
||||
return {
|
||||
id: this.id,
|
||||
entityType: this.entityType,
|
||||
entityId: this.entityId,
|
||||
feedUrl: this.feedURL,
|
||||
meta: {
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
preventIndexing: this.preventIndexing,
|
||||
ownerName: this.ownerName,
|
||||
ownerEmail: this.ownerEmail
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Feed
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
const Path = require('path')
|
||||
const { DataTypes, Model } = require('sequelize')
|
||||
const uuidv4 = require('uuid').v4
|
||||
const Logger = require('../Logger')
|
||||
const date = require('../libs/dateAndTime')
|
||||
|
||||
class FeedEpisode extends Model {
|
||||
constructor(values, options) {
|
||||
|
|
@ -40,29 +44,6 @@ class FeedEpisode extends Model {
|
|||
this.updatedAt
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create feed episode from old model
|
||||
*
|
||||
|
|
@ -96,6 +77,144 @@ class FeedEpisode extends Model {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
|
||||
* @param {import('./Feed')} feed
|
||||
* @param {string} slug
|
||||
* @param {import('./PodcastEpisode')} episode
|
||||
*/
|
||||
static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) {
|
||||
return {
|
||||
title: episode.title,
|
||||
author: feed.author,
|
||||
description: episode.description,
|
||||
siteURL: feed.siteURL,
|
||||
enclosureURL: `/feed/${slug}/item/${episode.id}/media${Path.extname(episode.audioFile.metadata.filename)}`,
|
||||
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
|
||||
*
|
||||
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static checkUseChapterTitlesForEpisodes(libraryItemExpanded) {
|
||||
const tracks = libraryItemExpanded.media.trackList || []
|
||||
const chapters = libraryItemExpanded.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
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('./LibraryItem').LibraryItemExpanded} libraryItemExpanded
|
||||
* @param {import('./Feed')} feed
|
||||
* @param {string} slug
|
||||
* @param {import('./Book').AudioFileObject} audioTrack
|
||||
* @param {boolean} useChapterTitles
|
||||
* @param {string} [pubDateOverride]
|
||||
*/
|
||||
static getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded, feed, slug, audioTrack, 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(libraryItemExpanded.createdAt.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]')
|
||||
|
||||
const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}`
|
||||
const media = libraryItemExpanded.media
|
||||
|
||||
let title = audioTrack.title
|
||||
if (media.trackList.length == 1) {
|
||||
// If audiobook is a single file, use book title instead of chapter/file title
|
||||
title = media.title
|
||||
} else {
|
||||
if (useChapterTitles) {
|
||||
// If audio track start and chapter start are within 1 seconds of eachother then use the chapter title
|
||||
const matchingChapter = media.chapters.find((ch) => Math.abs(ch.start - audioTrack.startOffset) < 1)
|
||||
if (matchingChapter?.title) title = matchingChapter.title
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: episodeId,
|
||||
title,
|
||||
author: feed.author,
|
||||
description: media.description || '',
|
||||
siteURL: feed.siteURL,
|
||||
enclosureURL: contentUrl,
|
||||
enclosureType: audioTrack.mimeType,
|
||||
enclosureSize: audioTrack.metadata.size,
|
||||
pubDate: audiobookPubDate,
|
||||
duration: audioTrack.duration,
|
||||
filePath: audioTrack.metadata.path,
|
||||
explicit: 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 createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) {
|
||||
const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded)
|
||||
|
||||
const feedEpisodeObjs = []
|
||||
for (const track of libraryItemExpanded.media.trackList) {
|
||||
feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded, feed, slug, track, useChapterTitles))
|
||||
}
|
||||
Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`)
|
||||
return this.bulkCreate(feedEpisodeObjs, { transaction })
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize model
|
||||
* @param {import('../Database').sequelize} sequelize
|
||||
|
|
@ -136,6 +255,29 @@ class FeedEpisode extends Model {
|
|||
})
|
||||
FeedEpisode.belongsTo(feed)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FeedEpisode
|
||||
|
|
|
|||
|
|
@ -73,6 +73,9 @@ class LibraryItem extends Model {
|
|||
this.createdAt
|
||||
/** @type {Date} */
|
||||
this.updatedAt
|
||||
|
||||
/** @type {Book.BookExpanded|Podcast.PodcastExpanded} - only set when expanded */
|
||||
this.media
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1124,6 +1127,24 @@ class LibraryItem extends Model {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if book or podcast library item has audio tracks
|
||||
* Requires expanded library item
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasAudioTracks() {
|
||||
if (!this.media) {
|
||||
Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`)
|
||||
return false
|
||||
}
|
||||
if (this.mediaType === 'book') {
|
||||
return this.media.audioFiles?.length > 0
|
||||
} else {
|
||||
return this.media.podcastEpisodes?.length > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LibraryItem
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue