270 lines
8.7 KiB
JavaScript
270 lines
8.7 KiB
JavaScript
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.UUID,
|
|
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<MediaProgress>}
|
|
*/
|
|
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
|