mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-08 00:08:14 +01:00
457 lines
14 KiB
JavaScript
457 lines
14 KiB
JavaScript
const { DataTypes, Model } = require('sequelize')
|
|
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
|
|
const Logger = require('../Logger')
|
|
|
|
/**
|
|
* @typedef PodcastExpandedProperties
|
|
* @property {import('./PodcastEpisode')[]} podcastEpisodes
|
|
*
|
|
* @typedef {Podcast & PodcastExpandedProperties} PodcastExpanded
|
|
*/
|
|
|
|
class Podcast extends Model {
|
|
constructor(values, options) {
|
|
super(values, options)
|
|
|
|
/** @type {string} */
|
|
this.id
|
|
/** @type {string} */
|
|
this.title
|
|
/** @type {string} */
|
|
this.titleIgnorePrefix
|
|
/** @type {string} */
|
|
this.author
|
|
/** @type {string} */
|
|
this.releaseDate
|
|
/** @type {string} */
|
|
this.feedURL
|
|
/** @type {string} */
|
|
this.imageURL
|
|
/** @type {string} */
|
|
this.description
|
|
/** @type {string} */
|
|
this.itunesPageURL
|
|
/** @type {string} */
|
|
this.itunesId
|
|
/** @type {string} */
|
|
this.itunesArtistId
|
|
/** @type {string} */
|
|
this.language
|
|
/** @type {string} */
|
|
this.podcastType
|
|
/** @type {boolean} */
|
|
this.explicit
|
|
/** @type {boolean} */
|
|
this.autoDownloadEpisodes
|
|
/** @type {string} */
|
|
this.autoDownloadSchedule
|
|
/** @type {Date} */
|
|
this.lastEpisodeCheck
|
|
/** @type {number} */
|
|
this.maxEpisodesToKeep
|
|
/** @type {number} */
|
|
this.maxNewEpisodesToDownload
|
|
/** @type {string} */
|
|
this.coverPath
|
|
/** @type {string[]} */
|
|
this.tags
|
|
/** @type {string[]} */
|
|
this.genres
|
|
/** @type {Date} */
|
|
this.createdAt
|
|
/** @type {Date} */
|
|
this.updatedAt
|
|
|
|
/** @type {import('./PodcastEpisode')[]} */
|
|
this.podcastEpisodes
|
|
}
|
|
|
|
/**
|
|
* Payload from the /api/podcasts POST endpoint
|
|
*
|
|
* @param {Object} payload
|
|
* @param {import('sequelize').Transaction} transaction
|
|
*/
|
|
static async createFromRequest(payload, transaction) {
|
|
const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null
|
|
const autoDownloadSchedule = typeof payload.autoDownloadSchedule === 'string' ? payload.autoDownloadSchedule : null
|
|
const genres = Array.isArray(payload.metadata.genres) && payload.metadata.genres.every((g) => typeof g === 'string' && g.length) ? payload.metadata.genres : []
|
|
const tags = Array.isArray(payload.tags) && payload.tags.every((t) => typeof t === 'string' && t.length) ? payload.tags : []
|
|
|
|
return this.create(
|
|
{
|
|
title,
|
|
titleIgnorePrefix: getTitleIgnorePrefix(title),
|
|
author: typeof payload.metadata.author === 'string' ? payload.metadata.author : null,
|
|
releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null,
|
|
feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null,
|
|
imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : null,
|
|
description: typeof payload.metadata.description === 'string' ? payload.metadata.description : null,
|
|
itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null,
|
|
itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null,
|
|
itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null,
|
|
language: typeof payload.metadata.language === 'string' ? payload.metadata.language : null,
|
|
podcastType: typeof payload.metadata.type === 'string' ? payload.metadata.type : null,
|
|
explicit: !!payload.metadata.explicit,
|
|
autoDownloadEpisodes: !!payload.autoDownloadEpisodes,
|
|
autoDownloadSchedule: autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule,
|
|
lastEpisodeCheck: new Date(),
|
|
maxEpisodesToKeep: 0,
|
|
maxNewEpisodesToDownload: 3,
|
|
tags,
|
|
genres
|
|
},
|
|
{ transaction }
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Initialize model
|
|
* @param {import('../Database').sequelize} sequelize
|
|
*/
|
|
static init(sequelize) {
|
|
super.init(
|
|
{
|
|
id: {
|
|
type: DataTypes.UUID,
|
|
defaultValue: DataTypes.UUIDV4,
|
|
primaryKey: true
|
|
},
|
|
title: DataTypes.STRING,
|
|
titleIgnorePrefix: DataTypes.STRING,
|
|
author: DataTypes.STRING,
|
|
releaseDate: DataTypes.STRING,
|
|
feedURL: DataTypes.STRING,
|
|
imageURL: DataTypes.STRING,
|
|
description: DataTypes.TEXT,
|
|
itunesPageURL: DataTypes.STRING,
|
|
itunesId: DataTypes.STRING,
|
|
itunesArtistId: DataTypes.STRING,
|
|
language: DataTypes.STRING,
|
|
podcastType: DataTypes.STRING,
|
|
explicit: DataTypes.BOOLEAN,
|
|
|
|
autoDownloadEpisodes: DataTypes.BOOLEAN,
|
|
autoDownloadSchedule: DataTypes.STRING,
|
|
lastEpisodeCheck: DataTypes.DATE,
|
|
maxEpisodesToKeep: DataTypes.INTEGER,
|
|
maxNewEpisodesToDownload: DataTypes.INTEGER,
|
|
coverPath: DataTypes.STRING,
|
|
tags: DataTypes.JSON,
|
|
genres: DataTypes.JSON
|
|
},
|
|
{
|
|
sequelize,
|
|
modelName: 'podcast'
|
|
}
|
|
)
|
|
}
|
|
|
|
get hasMediaFiles() {
|
|
return !!this.podcastEpisodes?.length
|
|
}
|
|
|
|
get hasAudioTracks() {
|
|
return this.hasMediaFiles
|
|
}
|
|
|
|
get size() {
|
|
if (!this.podcastEpisodes?.length) return 0
|
|
return this.podcastEpisodes.reduce((total, episode) => total + episode.size, 0)
|
|
}
|
|
|
|
getAbsMetadataJson() {
|
|
return {
|
|
tags: this.tags || [],
|
|
title: this.title,
|
|
author: this.author,
|
|
description: this.description,
|
|
releaseDate: this.releaseDate,
|
|
genres: this.genres || [],
|
|
feedURL: this.feedURL,
|
|
imageURL: this.imageURL,
|
|
itunesPageURL: this.itunesPageURL,
|
|
itunesId: this.itunesId,
|
|
itunesArtistId: this.itunesArtistId,
|
|
language: this.language,
|
|
explicit: !!this.explicit,
|
|
podcastType: this.podcastType
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {Object} payload - Old podcast object
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async updateFromRequest(payload) {
|
|
if (!payload) return false
|
|
|
|
let hasUpdates = false
|
|
|
|
if (payload.metadata) {
|
|
const stringKeys = ['title', 'author', 'releaseDate', 'feedUrl', 'imageUrl', 'description', 'itunesPageUrl', 'itunesId', 'itunesArtistId', 'language', 'type']
|
|
stringKeys.forEach((key) => {
|
|
let newKey = key
|
|
if (key === 'type') {
|
|
newKey = 'podcastType'
|
|
} else if (key === 'feedUrl') {
|
|
newKey = 'feedURL'
|
|
} else if (key === 'imageUrl') {
|
|
newKey = 'imageURL'
|
|
} else if (key === 'itunesPageUrl') {
|
|
newKey = 'itunesPageURL'
|
|
}
|
|
if (typeof payload.metadata[key] === 'string' && payload.metadata[key] !== this[newKey]) {
|
|
this[newKey] = payload.metadata[key]
|
|
if (key === 'title') {
|
|
this.titleIgnorePrefix = getTitleIgnorePrefix(this.title)
|
|
}
|
|
|
|
hasUpdates = true
|
|
}
|
|
})
|
|
|
|
if (payload.metadata.explicit !== undefined && payload.metadata.explicit !== this.explicit) {
|
|
this.explicit = !!payload.metadata.explicit
|
|
hasUpdates = true
|
|
}
|
|
|
|
if (Array.isArray(payload.metadata.genres) && !payload.metadata.genres.some((item) => typeof item !== 'string') && JSON.stringify(this.genres) !== JSON.stringify(payload.metadata.genres)) {
|
|
this.genres = payload.metadata.genres
|
|
this.changed('genres', true)
|
|
hasUpdates = true
|
|
}
|
|
}
|
|
|
|
if (Array.isArray(payload.tags) && !payload.tags.some((item) => typeof item !== 'string') && JSON.stringify(this.tags) !== JSON.stringify(payload.tags)) {
|
|
this.tags = payload.tags
|
|
this.changed('tags', true)
|
|
hasUpdates = true
|
|
}
|
|
|
|
if (payload.autoDownloadEpisodes !== undefined && payload.autoDownloadEpisodes !== this.autoDownloadEpisodes) {
|
|
this.autoDownloadEpisodes = !!payload.autoDownloadEpisodes
|
|
hasUpdates = true
|
|
}
|
|
if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) {
|
|
this.autoDownloadSchedule = payload.autoDownloadSchedule
|
|
hasUpdates = true
|
|
}
|
|
if (typeof payload.lastEpisodeCheck === 'number' && payload.lastEpisodeCheck !== this.lastEpisodeCheck?.valueOf()) {
|
|
this.lastEpisodeCheck = payload.lastEpisodeCheck
|
|
hasUpdates = true
|
|
}
|
|
|
|
const numberKeys = ['maxEpisodesToKeep', 'maxNewEpisodesToDownload']
|
|
numberKeys.forEach((key) => {
|
|
if (typeof payload[key] === 'number' && payload[key] !== this[key]) {
|
|
this[key] = payload[key]
|
|
hasUpdates = true
|
|
}
|
|
})
|
|
|
|
if (hasUpdates) {
|
|
Logger.debug(`[Podcast] changed keys:`, this.changed())
|
|
await this.save()
|
|
}
|
|
|
|
return hasUpdates
|
|
}
|
|
|
|
checkCanDirectPlay(supportedMimeTypes, episodeId) {
|
|
if (!Array.isArray(supportedMimeTypes)) {
|
|
Logger.error(`[Podcast] checkCanDirectPlay: supportedMimeTypes is not an array`, supportedMimeTypes)
|
|
return false
|
|
}
|
|
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
|
|
if (!episode) {
|
|
Logger.error(`[Podcast] checkCanDirectPlay: episode not found`, episodeId)
|
|
return false
|
|
}
|
|
return supportedMimeTypes.includes(episode.audioFile.mimeType)
|
|
}
|
|
|
|
/**
|
|
* Get the track list to be used in client audio players
|
|
* AudioTrack is the AudioFile with startOffset and contentUrl
|
|
* Podcast episodes only have one track
|
|
*
|
|
* @param {string} libraryItemId
|
|
* @param {string} episodeId
|
|
* @returns {import('./Book').AudioTrack[]}
|
|
*/
|
|
getTracklist(libraryItemId, episodeId) {
|
|
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
|
|
if (!episode) {
|
|
Logger.error(`[Podcast] getTracklist: episode not found`, episodeId)
|
|
return []
|
|
}
|
|
|
|
const audioTrack = episode.getAudioTrack(libraryItemId)
|
|
return [audioTrack]
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} episodeId
|
|
* @returns {import('./PodcastEpisode').ChapterObject[]}
|
|
*/
|
|
getChapters(episodeId) {
|
|
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
|
|
if (!episode) {
|
|
Logger.error(`[Podcast] getChapters: episode not found`, episodeId)
|
|
return []
|
|
}
|
|
|
|
return structuredClone(episode.chapters) || []
|
|
}
|
|
|
|
getPlaybackTitle(episodeId) {
|
|
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
|
|
if (!episode) {
|
|
Logger.error(`[Podcast] getPlaybackTitle: episode not found`, episodeId)
|
|
return ''
|
|
}
|
|
|
|
return episode.title
|
|
}
|
|
|
|
getPlaybackAuthor() {
|
|
return this.author
|
|
}
|
|
|
|
getPlaybackDuration(episodeId) {
|
|
const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId)
|
|
if (!episode) {
|
|
Logger.error(`[Podcast] getPlaybackDuration: episode not found`, episodeId)
|
|
return 0
|
|
}
|
|
|
|
return episode.duration
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @returns {number} - Unix timestamp
|
|
*/
|
|
getLatestEpisodePublishedAt() {
|
|
return this.podcastEpisodes.reduce((latest, episode) => {
|
|
if (episode.publishedAt?.valueOf() > latest) {
|
|
return episode.publishedAt.valueOf()
|
|
}
|
|
return latest
|
|
}, 0)
|
|
}
|
|
|
|
/**
|
|
* Used for checking if an rss feed episode is already in the podcast
|
|
*
|
|
* @param {import('../utils/podcastUtils').RssPodcastEpisode} feedEpisode - object from rss feed
|
|
* @returns {boolean}
|
|
*/
|
|
checkHasEpisodeByFeedEpisode(feedEpisode) {
|
|
const guid = feedEpisode.guid
|
|
const url = feedEpisode.enclosure.url
|
|
return this.podcastEpisodes.some((ep) => ep.checkMatchesGuidOrEnclosureUrl(guid, url))
|
|
}
|
|
|
|
/**
|
|
* Old model kept metadata in a separate object
|
|
*/
|
|
oldMetadataToJSON() {
|
|
return {
|
|
title: this.title,
|
|
author: this.author,
|
|
description: this.description,
|
|
releaseDate: this.releaseDate,
|
|
genres: [...(this.genres || [])],
|
|
feedUrl: this.feedURL,
|
|
imageUrl: this.imageURL,
|
|
itunesPageUrl: this.itunesPageURL,
|
|
itunesId: this.itunesId,
|
|
itunesArtistId: this.itunesArtistId,
|
|
explicit: this.explicit,
|
|
language: this.language,
|
|
type: this.podcastType
|
|
}
|
|
}
|
|
|
|
oldMetadataToJSONExpanded() {
|
|
const oldMetadataJSON = this.oldMetadataToJSON()
|
|
oldMetadataJSON.titleIgnorePrefix = getTitlePrefixAtEnd(this.title)
|
|
return oldMetadataJSON
|
|
}
|
|
|
|
/**
|
|
* The old model stored episodes with the podcast object
|
|
*
|
|
* @param {string} libraryItemId
|
|
*/
|
|
toOldJSON(libraryItemId) {
|
|
if (!libraryItemId) {
|
|
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
|
|
}
|
|
if (!this.podcastEpisodes) {
|
|
throw new Error(`[Podcast] Cannot convert to old JSON because episodes are not provided`)
|
|
}
|
|
|
|
return {
|
|
id: this.id,
|
|
libraryItemId: libraryItemId,
|
|
metadata: this.oldMetadataToJSON(),
|
|
coverPath: this.coverPath,
|
|
tags: [...(this.tags || [])],
|
|
episodes: this.podcastEpisodes.map((episode) => episode.toOldJSON(libraryItemId)),
|
|
autoDownloadEpisodes: this.autoDownloadEpisodes,
|
|
autoDownloadSchedule: this.autoDownloadSchedule,
|
|
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
|
|
maxEpisodesToKeep: this.maxEpisodesToKeep,
|
|
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload
|
|
}
|
|
}
|
|
|
|
toOldJSONMinified() {
|
|
return {
|
|
id: this.id,
|
|
// Minified metadata and expanded metadata are the same
|
|
metadata: this.oldMetadataToJSONExpanded(),
|
|
coverPath: this.coverPath,
|
|
tags: [...(this.tags || [])],
|
|
numEpisodes: this.podcastEpisodes?.length || 0,
|
|
autoDownloadEpisodes: this.autoDownloadEpisodes,
|
|
autoDownloadSchedule: this.autoDownloadSchedule,
|
|
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
|
|
maxEpisodesToKeep: this.maxEpisodesToKeep,
|
|
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
|
|
size: this.size
|
|
}
|
|
}
|
|
|
|
toOldJSONExpanded(libraryItemId) {
|
|
if (!libraryItemId) {
|
|
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
|
|
}
|
|
if (!this.podcastEpisodes) {
|
|
throw new Error(`[Podcast] Cannot convert to old JSON because episodes are not provided`)
|
|
}
|
|
|
|
return {
|
|
id: this.id,
|
|
libraryItemId: libraryItemId,
|
|
metadata: this.oldMetadataToJSONExpanded(),
|
|
coverPath: this.coverPath,
|
|
tags: [...(this.tags || [])],
|
|
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)),
|
|
autoDownloadEpisodes: this.autoDownloadEpisodes,
|
|
autoDownloadSchedule: this.autoDownloadSchedule,
|
|
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
|
|
maxEpisodesToKeep: this.maxEpisodesToKeep,
|
|
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
|
|
size: this.size
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = Podcast
|