const { DataTypes, Model } = require('sequelize') const Logger = require('../Logger') const { isNullOrNaN } = require('../utils') class MediaProgress extends Model { constructor(values, options) { super(values, options) /** @type {UUIDV4} */ this.id /** @type {UUIDV4} */ this.mediaItemId /** @type {string} */ this.mediaItemType /** @type {number} */ this.duration /** @type {number} */ this.currentTime /** @type {boolean} */ this.isFinished /** @type {boolean} */ this.hideFromContinueListening /** @type {string} */ this.ebookLocation /** @type {number} */ this.ebookProgress /** @type {Date} */ this.finishedAt /** @type {Object} */ this.extraData /** @type {UUIDV4} */ this.userId /** @type {Date} */ this.updatedAt /** @type {Date} */ this.createdAt } static upsertFromOld(oldMediaProgress) { const mediaProgress = this.getFromOld(oldMediaProgress) return this.upsert(mediaProgress) } static getFromOld(oldMediaProgress) { return { id: oldMediaProgress.id, userId: oldMediaProgress.userId, mediaItemId: oldMediaProgress.mediaItemId, mediaItemType: oldMediaProgress.mediaItemType, duration: oldMediaProgress.duration, currentTime: oldMediaProgress.currentTime, ebookLocation: oldMediaProgress.ebookLocation || null, ebookProgress: oldMediaProgress.ebookProgress || null, isFinished: !!oldMediaProgress.isFinished, hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening, finishedAt: oldMediaProgress.finishedAt, createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate, updatedAt: oldMediaProgress.lastUpdate, extraData: { libraryItemId: oldMediaProgress.libraryItemId, progress: oldMediaProgress.progress } } } static removeById(mediaProgressId) { return this.destroy({ where: { id: mediaProgressId } }) } getMediaItem(options) { if (!this.mediaItemType) return Promise.resolve(null) const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}` return this[mixinMethodName](options) } /** * Initialize model * * Polymorphic association: Book has many MediaProgress. PodcastEpisode has many MediaProgress. * @see https://sequelize.org/docs/v6/advanced-association-concepts/polymorphic-associations/ * * @param {import('../Database').sequelize} sequelize */ static init(sequelize) { super.init( { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true }, mediaItemId: DataTypes.UUIDV4, mediaItemType: DataTypes.STRING, duration: DataTypes.FLOAT, currentTime: DataTypes.FLOAT, isFinished: DataTypes.BOOLEAN, hideFromContinueListening: DataTypes.BOOLEAN, ebookLocation: DataTypes.STRING, ebookProgress: DataTypes.FLOAT, finishedAt: DataTypes.DATE, extraData: DataTypes.JSON }, { sequelize, modelName: 'mediaProgress', indexes: [ { fields: ['updatedAt'] } ] } ) const { book, podcastEpisode, user } = sequelize.models book.hasMany(MediaProgress, { foreignKey: 'mediaItemId', constraints: false, scope: { mediaItemType: 'book' } }) MediaProgress.belongsTo(book, { foreignKey: 'mediaItemId', constraints: false }) podcastEpisode.hasMany(MediaProgress, { foreignKey: 'mediaItemId', constraints: false, scope: { mediaItemType: 'podcastEpisode' } }) MediaProgress.belongsTo(podcastEpisode, { foreignKey: 'mediaItemId', constraints: false }) MediaProgress.addHook('afterFind', (findResult) => { if (!findResult) return if (!Array.isArray(findResult)) findResult = [findResult] for (const instance of findResult) { if (instance.mediaItemType === 'book' && instance.book !== undefined) { instance.mediaItem = instance.book instance.dataValues.mediaItem = instance.dataValues.book } else if (instance.mediaItemType === 'podcastEpisode' && instance.podcastEpisode !== undefined) { instance.mediaItem = instance.podcastEpisode instance.dataValues.mediaItem = instance.dataValues.podcastEpisode } // To prevent mistakes: delete instance.book delete instance.dataValues.book delete instance.podcastEpisode delete instance.dataValues.podcastEpisode } }) user.hasMany(MediaProgress, { onDelete: 'CASCADE' }) MediaProgress.belongsTo(user) } getOldMediaProgress() { const isPodcastEpisode = this.mediaItemType === 'podcastEpisode' return { id: this.id, userId: this.userId, libraryItemId: this.extraData?.libraryItemId || null, episodeId: isPodcastEpisode ? this.mediaItemId : null, mediaItemId: this.mediaItemId, mediaItemType: this.mediaItemType, duration: this.duration, progress: this.extraData?.progress || 0, currentTime: this.currentTime, isFinished: !!this.isFinished, hideFromContinueListening: !!this.hideFromContinueListening, ebookLocation: this.ebookLocation, ebookProgress: this.ebookProgress, lastUpdate: this.updatedAt.valueOf(), startedAt: this.createdAt.valueOf(), finishedAt: this.finishedAt?.valueOf() || null } } get progress() { // Value between 0 and 1 if (!this.duration) return 0 return Math.max(0, Math.min(this.currentTime / this.duration, 1)) } /** * Apply update to media progress * * @param {import('./User').ProgressUpdatePayload} progressPayload * @returns {Promise} */ applyProgressUpdate(progressPayload) { if (!this.extraData) this.extraData = {} if (progressPayload.isFinished !== undefined) { if (progressPayload.isFinished && !this.isFinished) { this.finishedAt = Date.now() this.extraData.progress = 1 this.changed('extraData', true) delete progressPayload.finishedAt } else if (!progressPayload.isFinished && this.isFinished) { this.finishedAt = null this.extraData.progress = 0 this.currentTime = 0 this.changed('extraData', true) delete progressPayload.finishedAt delete progressPayload.currentTime } } else if (!isNaN(progressPayload.progress) && progressPayload.progress !== this.progress) { // Old model stored progress on object this.extraData.progress = Math.min(1, Math.max(0, progressPayload.progress)) this.changed('extraData', true) } this.set(progressPayload) // Reset hideFromContinueListening if the progress has changed if (this.changed('currentTime') && !progressPayload.hideFromContinueListening) { this.hideFromContinueListening = false } const timeRemaining = this.duration - this.currentTime // Check if progress is far enough to mark as finished // - If markAsFinishedPercentComplete is provided, use that otherwise use markAsFinishedTimeRemaining (default 10 seconds) let shouldMarkAsFinished = false if (this.duration) { if (!isNullOrNaN(progressPayload.markAsFinishedPercentComplete) && progressPayload.markAsFinishedPercentComplete > 0) { const markAsFinishedPercentComplete = Number(progressPayload.markAsFinishedPercentComplete) / 100 shouldMarkAsFinished = markAsFinishedPercentComplete < this.progress if (shouldMarkAsFinished) { Logger.debug(`[MediaProgress] Marking media progress as finished because progress (${this.progress}) is greater than ${markAsFinishedPercentComplete}`) } } else { const markAsFinishedTimeRemaining = isNullOrNaN(progressPayload.markAsFinishedTimeRemaining) ? 10 : Number(progressPayload.markAsFinishedTimeRemaining) shouldMarkAsFinished = timeRemaining < markAsFinishedTimeRemaining if (shouldMarkAsFinished) { Logger.debug(`[MediaProgress] Marking media progress as finished because time remaining (${timeRemaining}) is less than ${markAsFinishedTimeRemaining} seconds`) } } } if (!this.isFinished && shouldMarkAsFinished) { this.isFinished = true this.finishedAt = this.finishedAt || Date.now() this.extraData.progress = 1 this.changed('extraData', true) } else if (this.isFinished && this.changed('currentTime') && !shouldMarkAsFinished) { this.isFinished = false this.finishedAt = null } // For local sync if (progressPayload.lastUpdate) { this.updatedAt = progressPayload.lastUpdate } return this.save() } } module.exports = MediaProgress