mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			458 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			458 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] === null) && payload.metadata[key] !== this[newKey]) {
 | 
						|
          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
 |