mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			480 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			480 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| const { DataTypes, Model } = require('sequelize')
 | |
| const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
 | |
| const Logger = require('../Logger')
 | |
| const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
 | |
| const htmlSanitizer = require('../utils/htmlSanitizer')
 | |
| 
 | |
| /**
 | |
|  * @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 {number} */
 | |
|     this.numEpisodes
 | |
| 
 | |
|     /** @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,
 | |
|         numEpisodes: DataTypes.INTEGER
 | |
|       },
 | |
|       {
 | |
|         sequelize,
 | |
|         modelName: 'podcast'
 | |
|       }
 | |
|     )
 | |
| 
 | |
|     Podcast.addHook('afterDestroy', async (instance) => {
 | |
|       libraryItemsPodcastFilters.clearCountCache('podcast', 'afterDestroy')
 | |
|     })
 | |
| 
 | |
|     Podcast.addHook('afterCreate', async (instance) => {
 | |
|       libraryItemsPodcastFilters.clearCountCache('podcast', 'afterCreate')
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   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] === null) && payload.metadata[key] !== this[newKey]) {
 | |
|           // Sanitize description HTML
 | |
|           if (key === 'description' && payload.metadata[key]) {
 | |
|             const sanitizedDescription = htmlSanitizer.sanitize(payload.metadata[key])
 | |
|             if (sanitizedDescription !== payload.metadata[key]) {
 | |
|               Logger.debug(`[Podcast] "${this.title}" Sanitized description from "${payload.metadata[key]}" to "${sanitizedDescription}"`)
 | |
|               payload.metadata[key] = sanitizedDescription
 | |
|             }
 | |
|           }
 | |
| 
 | |
|           this[newKey] = payload.metadata[key] || null
 | |
| 
 | |
|           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
 |