2024-05-29 00:24:02 +02:00
|
|
|
const uuidv4 = require('uuid').v4
|
2023-11-22 18:00:11 +01:00
|
|
|
const sequelize = require('sequelize')
|
2023-07-05 01:14:44 +02:00
|
|
|
const Logger = require('../Logger')
|
2023-11-22 18:00:11 +01:00
|
|
|
const SocketAuthority = require('../SocketAuthority')
|
2024-08-11 18:53:30 +02:00
|
|
|
const { isNullOrNaN } = require('../utils')
|
|
|
|
|
2023-11-22 18:00:11 +01:00
|
|
|
const { DataTypes, Model } = sequelize
|
2023-07-05 01:14:44 +02:00
|
|
|
|
2024-08-11 19:16:45 +02:00
|
|
|
/**
|
|
|
|
* @typedef AudioBookmarkObject
|
|
|
|
* @property {string} libraryItemId
|
|
|
|
* @property {string} title
|
|
|
|
* @property {number} time
|
|
|
|
* @property {number} createdAt
|
|
|
|
*/
|
|
|
|
|
2024-10-22 00:48:02 +02:00
|
|
|
/**
|
|
|
|
* @typedef ProgressUpdatePayload
|
|
|
|
* @property {string} libraryItemId
|
|
|
|
* @property {string} [episodeId]
|
|
|
|
* @property {number} [duration]
|
|
|
|
* @property {number} [progress]
|
|
|
|
* @property {number} [currentTime]
|
|
|
|
* @property {boolean} [isFinished]
|
|
|
|
* @property {boolean} [hideFromContinueListening]
|
|
|
|
* @property {string} [ebookLocation]
|
|
|
|
* @property {number} [ebookProgress]
|
|
|
|
* @property {string} [finishedAt]
|
|
|
|
* @property {number} [lastUpdate]
|
|
|
|
* @property {number} [markAsFinishedTimeRemaining]
|
2024-10-25 00:19:51 +02:00
|
|
|
* @property {number} [markAsFinishedPercentComplete]
|
2024-10-22 00:48:02 +02:00
|
|
|
*/
|
|
|
|
|
2023-08-16 23:38:48 +02:00
|
|
|
class User extends Model {
|
|
|
|
constructor(values, options) {
|
|
|
|
super(values, options)
|
2023-07-05 01:14:44 +02:00
|
|
|
|
2023-08-16 23:38:48 +02:00
|
|
|
/** @type {UUIDV4} */
|
|
|
|
this.id
|
|
|
|
/** @type {string} */
|
|
|
|
this.username
|
|
|
|
/** @type {string} */
|
|
|
|
this.email
|
|
|
|
/** @type {string} */
|
|
|
|
this.pash
|
|
|
|
/** @type {string} */
|
|
|
|
this.type
|
2024-08-04 23:13:40 +02:00
|
|
|
/** @type {string} */
|
|
|
|
this.token
|
2023-08-16 23:38:48 +02:00
|
|
|
/** @type {boolean} */
|
|
|
|
this.isActive
|
|
|
|
/** @type {boolean} */
|
|
|
|
this.isLocked
|
|
|
|
/** @type {Date} */
|
|
|
|
this.lastSeen
|
|
|
|
/** @type {Object} */
|
|
|
|
this.permissions
|
2024-08-11 19:16:45 +02:00
|
|
|
/** @type {AudioBookmarkObject[]} */
|
2023-08-16 23:38:48 +02:00
|
|
|
this.bookmarks
|
|
|
|
/** @type {Object} */
|
|
|
|
this.extraData
|
|
|
|
/** @type {Date} */
|
|
|
|
this.createdAt
|
|
|
|
/** @type {Date} */
|
|
|
|
this.updatedAt
|
2024-08-04 23:13:40 +02:00
|
|
|
/** @type {import('./MediaProgress')[]?} - Only included when extended */
|
|
|
|
this.mediaProgresses
|
2023-08-16 23:38:48 +02:00
|
|
|
}
|
2023-07-05 01:14:44 +02:00
|
|
|
|
2024-08-18 00:18:40 +02:00
|
|
|
// Excludes "root" since their can only be 1 root user
|
|
|
|
static accountTypes = ['admin', 'user', 'guest']
|
|
|
|
|
2024-08-11 23:07:29 +02:00
|
|
|
/**
|
|
|
|
* List of expected permission properties from the client
|
|
|
|
* Only used for OpenID
|
|
|
|
*/
|
|
|
|
static permissionMapping = {
|
|
|
|
canDownload: 'download',
|
|
|
|
canUpload: 'upload',
|
|
|
|
canDelete: 'delete',
|
|
|
|
canUpdate: 'update',
|
|
|
|
canAccessExplicitContent: 'accessExplicitContent',
|
|
|
|
canAccessAllLibraries: 'accessAllLibraries',
|
|
|
|
canAccessAllTags: 'accessAllTags',
|
2024-10-26 22:34:34 +02:00
|
|
|
canCreateEReader: 'createEreader',
|
2024-08-11 23:07:29 +02:00
|
|
|
tagsAreDenylist: 'selectedTagsNotAccessible',
|
|
|
|
// Direct mapping for array-based permissions
|
|
|
|
allowedLibraries: 'librariesAccessible',
|
|
|
|
allowedTags: 'itemTagsSelected'
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get a sample to show how a JSON for updatePermissionsFromExternalJSON should look like
|
|
|
|
* Only used for OpenID
|
|
|
|
*
|
|
|
|
* @returns {string} JSON string
|
|
|
|
*/
|
|
|
|
static getSampleAbsPermissions() {
|
|
|
|
// Start with a template object where all permissions are false for simplicity
|
|
|
|
const samplePermissions = Object.keys(User.permissionMapping).reduce((acc, key) => {
|
|
|
|
// For array-based permissions, provide a sample array
|
|
|
|
if (key === 'allowedLibraries') {
|
|
|
|
acc[key] = [`5406ba8a-16e1-451d-96d7-4931b0a0d966`, `918fd848-7c1d-4a02-818a-847435a879ca`]
|
|
|
|
} else if (key === 'allowedTags') {
|
|
|
|
acc[key] = [`ExampleTag`, `AnotherTag`, `ThirdTag`]
|
|
|
|
} else {
|
|
|
|
acc[key] = false
|
|
|
|
}
|
|
|
|
return acc
|
|
|
|
}, {})
|
|
|
|
|
|
|
|
return JSON.stringify(samplePermissions, null, 2) // Pretty print the JSON
|
|
|
|
}
|
|
|
|
|
2023-08-16 23:38:48 +02:00
|
|
|
/**
|
2024-08-10 22:46:04 +02:00
|
|
|
*
|
|
|
|
* @param {string} type
|
|
|
|
* @returns
|
2023-08-16 23:38:48 +02:00
|
|
|
*/
|
2024-08-10 22:46:04 +02:00
|
|
|
static getDefaultPermissionsForUserType(type) {
|
|
|
|
return {
|
|
|
|
download: true,
|
|
|
|
update: type === 'root' || type === 'admin',
|
|
|
|
delete: type === 'root',
|
|
|
|
upload: type === 'root' || type === 'admin',
|
2024-10-26 22:34:34 +02:00
|
|
|
createEreader: type === 'root' || type === 'admin',
|
2024-08-10 22:46:04 +02:00
|
|
|
accessAllLibraries: true,
|
|
|
|
accessAllTags: true,
|
2024-09-12 07:09:04 +02:00
|
|
|
accessExplicitContent: type === 'root' || type === 'admin',
|
2024-09-02 19:08:17 +02:00
|
|
|
selectedTagsNotAccessible: false,
|
2024-08-10 22:46:04 +02:00
|
|
|
librariesAccessible: [],
|
|
|
|
itemTagsSelected: []
|
|
|
|
}
|
2023-08-16 23:38:48 +02:00
|
|
|
}
|
2023-07-05 01:14:44 +02:00
|
|
|
|
2023-08-16 23:38:48 +02:00
|
|
|
/**
|
|
|
|
* Create root user
|
2024-05-29 00:24:02 +02:00
|
|
|
* @param {string} username
|
|
|
|
* @param {string} pash
|
2024-08-10 22:46:04 +02:00
|
|
|
* @param {import('../Auth')} auth
|
|
|
|
* @returns {Promise<User>}
|
2023-08-16 23:38:48 +02:00
|
|
|
*/
|
|
|
|
static async createRootUser(username, pash, auth) {
|
|
|
|
const userId = uuidv4()
|
2023-07-22 22:32:20 +02:00
|
|
|
|
2023-11-22 18:00:11 +01:00
|
|
|
const token = await auth.generateAccessToken({ id: userId, username })
|
2023-07-22 22:32:20 +02:00
|
|
|
|
2024-08-10 22:46:04 +02:00
|
|
|
const newUser = {
|
2023-08-16 23:38:48 +02:00
|
|
|
id: userId,
|
|
|
|
type: 'root',
|
|
|
|
username,
|
|
|
|
pash,
|
|
|
|
token,
|
|
|
|
isActive: true,
|
2024-08-10 22:46:04 +02:00
|
|
|
permissions: this.getDefaultPermissionsForUserType('root'),
|
|
|
|
bookmarks: [],
|
|
|
|
extraData: {
|
|
|
|
seriesHideFromContinueListening: []
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return this.create(newUser)
|
2023-08-16 23:38:48 +02:00
|
|
|
}
|
2023-07-22 22:32:20 +02:00
|
|
|
|
2023-11-22 18:00:11 +01:00
|
|
|
/**
|
|
|
|
* Create user from openid userinfo
|
2024-05-29 00:24:02 +02:00
|
|
|
* @param {Object} userinfo
|
2024-08-10 22:46:04 +02:00
|
|
|
* @param {import('../Auth')} auth
|
|
|
|
* @returns {Promise<User>}
|
2023-11-22 18:00:11 +01:00
|
|
|
*/
|
|
|
|
static async createUserFromOpenIdUserInfo(userinfo, auth) {
|
|
|
|
const userId = uuidv4()
|
|
|
|
// TODO: Ensure username is unique?
|
|
|
|
const username = userinfo.preferred_username || userinfo.name || userinfo.sub
|
2024-05-29 00:24:02 +02:00
|
|
|
const email = userinfo.email && userinfo.email_verified ? userinfo.email : null
|
2023-11-22 18:00:11 +01:00
|
|
|
|
|
|
|
const token = await auth.generateAccessToken({ id: userId, username })
|
|
|
|
|
2024-08-10 22:46:04 +02:00
|
|
|
const newUser = {
|
2023-11-22 18:00:11 +01:00
|
|
|
id: userId,
|
|
|
|
type: 'user',
|
|
|
|
username,
|
|
|
|
email,
|
|
|
|
pash: null,
|
|
|
|
token,
|
|
|
|
isActive: true,
|
2024-08-10 22:46:04 +02:00
|
|
|
permissions: this.getDefaultPermissionsForUserType('user'),
|
|
|
|
bookmarks: [],
|
|
|
|
extraData: {
|
|
|
|
authOpenIDSub: userinfo.sub,
|
|
|
|
seriesHideFromContinueListening: []
|
|
|
|
}
|
2023-11-22 18:00:11 +01:00
|
|
|
}
|
2024-08-10 22:46:04 +02:00
|
|
|
const user = await this.create(newUser)
|
2023-11-22 18:00:11 +01:00
|
|
|
|
2024-08-10 22:46:04 +02:00
|
|
|
if (user) {
|
|
|
|
SocketAuthority.adminEmitter('user_added', user.toOldJSONForBrowser())
|
|
|
|
return user
|
|
|
|
}
|
|
|
|
return null
|
2023-08-16 23:38:48 +02:00
|
|
|
}
|
2023-07-22 22:32:20 +02:00
|
|
|
|
2023-08-16 23:38:48 +02:00
|
|
|
/**
|
|
|
|
* Get user by username case insensitive
|
2024-05-29 00:24:02 +02:00
|
|
|
* @param {string} username
|
2024-08-10 22:46:04 +02:00
|
|
|
* @returns {Promise<User>}
|
2023-08-16 23:38:48 +02:00
|
|
|
*/
|
|
|
|
static async getUserByUsername(username) {
|
|
|
|
if (!username) return null
|
2024-08-10 22:46:04 +02:00
|
|
|
return this.findOne({
|
2023-08-16 23:38:48 +02:00
|
|
|
where: {
|
|
|
|
username: {
|
2023-11-22 18:00:11 +01:00
|
|
|
[sequelize.Op.like]: username
|
|
|
|
}
|
|
|
|
},
|
|
|
|
include: this.sequelize.models.mediaProgress
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get user by email case insensitive
|
2024-08-10 22:46:04 +02:00
|
|
|
* @param {string} email
|
|
|
|
* @returns {Promise<User>}
|
2023-11-22 18:00:11 +01:00
|
|
|
*/
|
|
|
|
static async getUserByEmail(email) {
|
|
|
|
if (!email) return null
|
2024-08-10 22:46:04 +02:00
|
|
|
return this.findOne({
|
2023-11-22 18:00:11 +01:00
|
|
|
where: {
|
|
|
|
email: {
|
|
|
|
[sequelize.Op.like]: email
|
2023-07-22 22:32:20 +02:00
|
|
|
}
|
2023-08-16 23:38:48 +02:00
|
|
|
},
|
|
|
|
include: this.sequelize.models.mediaProgress
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get user by id
|
2024-05-29 00:24:02 +02:00
|
|
|
* @param {string} userId
|
2024-08-10 22:46:04 +02:00
|
|
|
* @returns {Promise<User>}
|
2023-08-16 23:38:48 +02:00
|
|
|
*/
|
|
|
|
static async getUserById(userId) {
|
|
|
|
if (!userId) return null
|
2024-08-10 22:46:04 +02:00
|
|
|
return this.findByPk(userId, {
|
|
|
|
include: this.sequelize.models.mediaProgress
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get user by id or old id
|
|
|
|
* JWT tokens generated before 2.3.0 used old user ids
|
|
|
|
*
|
|
|
|
* @param {string} userId
|
|
|
|
* @returns {Promise<User>}
|
|
|
|
*/
|
|
|
|
static async getUserByIdOrOldId(userId) {
|
|
|
|
if (!userId) return null
|
|
|
|
return this.findOne({
|
|
|
|
where: {
|
|
|
|
[sequelize.Op.or]: [{ id: userId }, { 'extraData.oldUserId': userId }]
|
|
|
|
},
|
2023-08-16 23:38:48 +02:00
|
|
|
include: this.sequelize.models.mediaProgress
|
|
|
|
})
|
2024-08-10 22:46:04 +02:00
|
|
|
}
|
|
|
|
|
2023-11-22 18:00:11 +01:00
|
|
|
/**
|
|
|
|
* Get user by openid sub
|
2024-05-29 00:24:02 +02:00
|
|
|
* @param {string} sub
|
2024-08-10 22:46:04 +02:00
|
|
|
* @returns {Promise<User>}
|
2023-11-22 18:00:11 +01:00
|
|
|
*/
|
|
|
|
static async getUserByOpenIDSub(sub) {
|
|
|
|
if (!sub) return null
|
2024-08-10 22:46:04 +02:00
|
|
|
return this.findOne({
|
2023-11-22 18:00:11 +01:00
|
|
|
where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub),
|
|
|
|
include: this.sequelize.models.mediaProgress
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2023-08-16 23:38:48 +02:00
|
|
|
/**
|
|
|
|
* Get array of user id and username
|
|
|
|
* @returns {object[]} { id, username }
|
|
|
|
*/
|
|
|
|
static async getMinifiedUserObjects() {
|
|
|
|
const users = await this.findAll({
|
|
|
|
attributes: ['id', 'username']
|
|
|
|
})
|
2024-05-29 00:24:02 +02:00
|
|
|
return users.map((u) => {
|
2023-08-16 23:38:48 +02:00
|
|
|
return {
|
|
|
|
id: u.id,
|
|
|
|
username: u.username
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Return true if root user exists
|
|
|
|
* @returns {boolean}
|
|
|
|
*/
|
|
|
|
static async getHasRootUser() {
|
|
|
|
const count = await this.count({
|
|
|
|
where: {
|
|
|
|
type: 'root'
|
|
|
|
}
|
|
|
|
})
|
|
|
|
return count > 0
|
|
|
|
}
|
|
|
|
|
2024-08-10 22:46:04 +02:00
|
|
|
/**
|
|
|
|
* Check if user exists with username
|
|
|
|
* @param {string} username
|
|
|
|
* @returns {boolean}
|
|
|
|
*/
|
|
|
|
static async checkUserExistsWithUsername(username) {
|
|
|
|
const count = await this.count({
|
|
|
|
where: {
|
|
|
|
username
|
|
|
|
}
|
|
|
|
})
|
|
|
|
return count > 0
|
|
|
|
}
|
|
|
|
|
2023-08-16 23:38:48 +02:00
|
|
|
/**
|
|
|
|
* Initialize model
|
2024-05-29 00:24:02 +02:00
|
|
|
* @param {import('../Database').sequelize} sequelize
|
2023-08-16 23:38:48 +02:00
|
|
|
*/
|
|
|
|
static init(sequelize) {
|
2024-05-29 00:24:02 +02:00
|
|
|
super.init(
|
|
|
|
{
|
|
|
|
id: {
|
|
|
|
type: DataTypes.UUID,
|
|
|
|
defaultValue: DataTypes.UUIDV4,
|
|
|
|
primaryKey: true
|
|
|
|
},
|
|
|
|
username: DataTypes.STRING,
|
|
|
|
email: DataTypes.STRING,
|
|
|
|
pash: DataTypes.STRING,
|
|
|
|
type: DataTypes.STRING,
|
|
|
|
token: DataTypes.STRING,
|
|
|
|
isActive: {
|
|
|
|
type: DataTypes.BOOLEAN,
|
|
|
|
defaultValue: false
|
|
|
|
},
|
|
|
|
isLocked: {
|
|
|
|
type: DataTypes.BOOLEAN,
|
|
|
|
defaultValue: false
|
|
|
|
},
|
|
|
|
lastSeen: DataTypes.DATE,
|
|
|
|
permissions: DataTypes.JSON,
|
|
|
|
bookmarks: DataTypes.JSON,
|
|
|
|
extraData: DataTypes.JSON
|
2023-08-16 23:38:48 +02:00
|
|
|
},
|
2024-05-29 00:24:02 +02:00
|
|
|
{
|
|
|
|
sequelize,
|
|
|
|
modelName: 'user'
|
|
|
|
}
|
|
|
|
)
|
2023-07-05 01:14:44 +02:00
|
|
|
}
|
2024-08-10 22:46:04 +02:00
|
|
|
|
2024-08-11 22:15:34 +02:00
|
|
|
get isRoot() {
|
|
|
|
return this.type === 'root'
|
|
|
|
}
|
2024-08-10 22:46:04 +02:00
|
|
|
get isAdminOrUp() {
|
2024-08-11 22:15:34 +02:00
|
|
|
return this.isRoot || this.type === 'admin'
|
2024-08-10 22:46:04 +02:00
|
|
|
}
|
|
|
|
get isUser() {
|
|
|
|
return this.type === 'user'
|
|
|
|
}
|
2024-08-11 20:09:53 +02:00
|
|
|
get isGuest() {
|
|
|
|
return this.type === 'guest'
|
|
|
|
}
|
2024-08-11 00:15:21 +02:00
|
|
|
get canAccessExplicitContent() {
|
|
|
|
return !!this.permissions?.accessExplicitContent && this.isActive
|
|
|
|
}
|
|
|
|
get canDelete() {
|
|
|
|
return !!this.permissions?.delete && this.isActive
|
|
|
|
}
|
|
|
|
get canUpdate() {
|
|
|
|
return !!this.permissions?.update && this.isActive
|
|
|
|
}
|
|
|
|
get canDownload() {
|
|
|
|
return !!this.permissions?.download && this.isActive
|
|
|
|
}
|
|
|
|
get canUpload() {
|
|
|
|
return !!this.permissions?.upload && this.isActive
|
|
|
|
}
|
2024-08-10 22:46:04 +02:00
|
|
|
/** @type {string|null} */
|
|
|
|
get authOpenIDSub() {
|
|
|
|
return this.extraData?.authOpenIDSub || null
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* User data for clients
|
|
|
|
* Emitted on socket events user_online, user_offline and user_stream_update
|
|
|
|
*
|
|
|
|
* @param {import('../objects/PlaybackSession')[]} sessions
|
|
|
|
* @returns
|
|
|
|
*/
|
|
|
|
toJSONForPublic(sessions) {
|
|
|
|
const session = sessions?.find((s) => s.userId === this.id)?.toJSONForClient() || null
|
|
|
|
return {
|
|
|
|
id: this.id,
|
|
|
|
username: this.username,
|
|
|
|
type: this.type,
|
|
|
|
session,
|
|
|
|
lastSeen: this.lastSeen?.valueOf() || null,
|
|
|
|
createdAt: this.createdAt.valueOf()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* User data for browser using old model
|
|
|
|
*
|
|
|
|
* @param {boolean} [hideRootToken=false]
|
|
|
|
* @param {boolean} [minimal=false]
|
|
|
|
* @returns
|
|
|
|
*/
|
|
|
|
toOldJSONForBrowser(hideRootToken = false, minimal = false) {
|
|
|
|
const seriesHideFromContinueListening = this.extraData?.seriesHideFromContinueListening || []
|
|
|
|
const librariesAccessible = this.permissions?.librariesAccessible || []
|
|
|
|
const itemTagsSelected = this.permissions?.itemTagsSelected || []
|
|
|
|
const permissions = { ...this.permissions }
|
|
|
|
delete permissions.librariesAccessible
|
|
|
|
delete permissions.itemTagsSelected
|
|
|
|
|
|
|
|
const json = {
|
|
|
|
id: this.id,
|
|
|
|
username: this.username,
|
|
|
|
email: this.email,
|
|
|
|
type: this.type,
|
|
|
|
token: this.type === 'root' && hideRootToken ? '' : this.token,
|
|
|
|
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
|
|
|
|
seriesHideFromContinueListening: [...seriesHideFromContinueListening],
|
|
|
|
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],
|
|
|
|
isActive: this.isActive,
|
|
|
|
isLocked: this.isLocked,
|
|
|
|
lastSeen: this.lastSeen?.valueOf() || null,
|
|
|
|
createdAt: this.createdAt.valueOf(),
|
|
|
|
permissions: permissions,
|
|
|
|
librariesAccessible: [...librariesAccessible],
|
|
|
|
itemTagsSelected: [...itemTagsSelected],
|
|
|
|
hasOpenIDLink: !!this.authOpenIDSub
|
|
|
|
}
|
|
|
|
if (minimal) {
|
|
|
|
delete json.mediaProgress
|
|
|
|
delete json.bookmarks
|
|
|
|
}
|
|
|
|
return json
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Check user has access to library
|
|
|
|
*
|
|
|
|
* @param {string} libraryId
|
|
|
|
* @returns {boolean}
|
|
|
|
*/
|
|
|
|
checkCanAccessLibrary(libraryId) {
|
|
|
|
if (this.permissions?.accessAllLibraries) return true
|
|
|
|
if (!this.permissions?.librariesAccessible) return false
|
|
|
|
return this.permissions.librariesAccessible.includes(libraryId)
|
|
|
|
}
|
|
|
|
|
2024-08-11 00:15:21 +02:00
|
|
|
/**
|
|
|
|
* Check user has access to library item with tags
|
|
|
|
*
|
|
|
|
* @param {string[]} tags
|
|
|
|
* @returns {boolean}
|
|
|
|
*/
|
|
|
|
checkCanAccessLibraryItemWithTags(tags) {
|
|
|
|
if (this.permissions.accessAllTags) return true
|
|
|
|
const itemTagsSelected = this.permissions?.itemTagsSelected || []
|
|
|
|
if (this.permissions.selectedTagsNotAccessible) {
|
|
|
|
if (!tags?.length) return true
|
|
|
|
return tags.every((tag) => !itemTagsSelected?.includes(tag))
|
|
|
|
}
|
|
|
|
if (!tags?.length) return false
|
|
|
|
return itemTagsSelected.some((tag) => tags.includes(tag))
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Check user can access library item
|
|
|
|
* TODO: Currently supports both old and new library item models
|
|
|
|
*
|
|
|
|
* @param {import('../objects/LibraryItem')|import('./LibraryItem')} libraryItem
|
|
|
|
* @returns {boolean}
|
|
|
|
*/
|
|
|
|
checkCanAccessLibraryItem(libraryItem) {
|
|
|
|
if (!this.checkCanAccessLibrary(libraryItem.libraryId)) return false
|
|
|
|
|
|
|
|
const libraryItemExplicit = !!libraryItem.media.explicit || !!libraryItem.media.metadata?.explicit
|
|
|
|
|
|
|
|
if (libraryItemExplicit && !this.canAccessExplicitContent) return false
|
|
|
|
|
|
|
|
return this.checkCanAccessLibraryItemWithTags(libraryItem.media.tags)
|
|
|
|
}
|
|
|
|
|
2024-08-10 22:46:04 +02:00
|
|
|
/**
|
|
|
|
* Get first available library id for user
|
|
|
|
*
|
|
|
|
* @param {string[]} libraryIds
|
|
|
|
* @returns {string|null}
|
|
|
|
*/
|
|
|
|
getDefaultLibraryId(libraryIds) {
|
|
|
|
// Libraries should already be in ascending display order, find first accessible
|
|
|
|
return libraryIds.find((lid) => this.checkCanAccessLibrary(lid)) || null
|
|
|
|
}
|
2024-08-11 00:15:21 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Get media progress by media item id
|
|
|
|
*
|
|
|
|
* @param {string} libraryItemId
|
|
|
|
* @param {string|null} [episodeId]
|
|
|
|
* @returns {import('./MediaProgress')|null}
|
|
|
|
*/
|
|
|
|
getMediaProgress(mediaItemId) {
|
|
|
|
if (!this.mediaProgresses?.length) return null
|
|
|
|
return this.mediaProgresses.find((mp) => mp.mediaItemId === mediaItemId)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get old media progress
|
|
|
|
* TODO: Update to new model
|
|
|
|
*
|
|
|
|
* @param {string} libraryItemId
|
|
|
|
* @param {string} [episodeId]
|
|
|
|
* @returns
|
|
|
|
*/
|
|
|
|
getOldMediaProgress(libraryItemId, episodeId = null) {
|
|
|
|
const mediaProgress = this.mediaProgresses?.find((mp) => {
|
|
|
|
if (episodeId && mp.mediaItemId === episodeId) return true
|
|
|
|
return mp.extraData?.libraryItemId === libraryItemId
|
|
|
|
})
|
|
|
|
return mediaProgress?.getOldMediaProgress() || null
|
|
|
|
}
|
2024-08-11 18:53:30 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* TODO: Uses old model and should account for the different between ebook/audiobook progress
|
|
|
|
*
|
|
|
|
* @param {ProgressUpdatePayload} progressPayload
|
|
|
|
* @returns {Promise<{ mediaProgress: import('./MediaProgress'), error: [string], statusCode: [number] }>}
|
|
|
|
*/
|
|
|
|
async createUpdateMediaProgressFromPayload(progressPayload) {
|
|
|
|
/** @type {import('./MediaProgress')|null} */
|
|
|
|
let mediaProgress = null
|
|
|
|
let mediaItemId = null
|
|
|
|
if (progressPayload.episodeId) {
|
|
|
|
const podcastEpisode = await this.sequelize.models.podcastEpisode.findByPk(progressPayload.episodeId, {
|
|
|
|
attributes: ['id', 'podcastId'],
|
|
|
|
include: [
|
|
|
|
{
|
|
|
|
model: this.sequelize.models.mediaProgress,
|
|
|
|
where: { userId: this.id },
|
|
|
|
required: false
|
|
|
|
},
|
|
|
|
{
|
|
|
|
model: this.sequelize.models.podcast,
|
|
|
|
attributes: ['id', 'title'],
|
|
|
|
include: {
|
|
|
|
model: this.sequelize.models.libraryItem,
|
|
|
|
attributes: ['id']
|
|
|
|
}
|
|
|
|
}
|
|
|
|
]
|
|
|
|
})
|
|
|
|
if (!podcastEpisode) {
|
|
|
|
Logger.error(`[User] createUpdateMediaProgress: episode ${progressPayload.episodeId} not found`)
|
|
|
|
return {
|
|
|
|
error: 'Episode not found',
|
|
|
|
statusCode: 404
|
|
|
|
}
|
|
|
|
}
|
|
|
|
mediaItemId = podcastEpisode.id
|
|
|
|
mediaProgress = podcastEpisode.mediaProgresses?.[0]
|
|
|
|
} else {
|
|
|
|
const libraryItem = await this.sequelize.models.libraryItem.findByPk(progressPayload.libraryItemId, {
|
|
|
|
attributes: ['id', 'mediaId', 'mediaType'],
|
|
|
|
include: {
|
|
|
|
model: this.sequelize.models.book,
|
|
|
|
attributes: ['id', 'title'],
|
|
|
|
required: false,
|
|
|
|
include: {
|
|
|
|
model: this.sequelize.models.mediaProgress,
|
|
|
|
where: { userId: this.id },
|
|
|
|
required: false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
if (!libraryItem) {
|
|
|
|
Logger.error(`[User] createUpdateMediaProgress: library item ${progressPayload.libraryItemId} not found`)
|
|
|
|
return {
|
|
|
|
error: 'Library item not found',
|
|
|
|
statusCode: 404
|
|
|
|
}
|
|
|
|
}
|
|
|
|
mediaItemId = libraryItem.media.id
|
|
|
|
mediaProgress = libraryItem.media.mediaProgresses?.[0]
|
|
|
|
}
|
|
|
|
|
|
|
|
if (mediaProgress) {
|
|
|
|
mediaProgress = await mediaProgress.applyProgressUpdate(progressPayload)
|
|
|
|
this.mediaProgresses = this.mediaProgresses.map((mp) => (mp.id === mediaProgress.id ? mediaProgress : mp))
|
|
|
|
} else {
|
|
|
|
const newMediaProgressPayload = {
|
|
|
|
userId: this.id,
|
|
|
|
mediaItemId,
|
|
|
|
mediaItemType: progressPayload.episodeId ? 'podcastEpisode' : 'book',
|
|
|
|
duration: isNullOrNaN(progressPayload.duration) ? 0 : Number(progressPayload.duration),
|
|
|
|
currentTime: isNullOrNaN(progressPayload.currentTime) ? 0 : Number(progressPayload.currentTime),
|
|
|
|
isFinished: !!progressPayload.isFinished,
|
|
|
|
hideFromContinueListening: !!progressPayload.hideFromContinueListening,
|
|
|
|
ebookLocation: progressPayload.ebookLocation || null,
|
|
|
|
ebookProgress: isNullOrNaN(progressPayload.ebookProgress) ? 0 : Number(progressPayload.ebookProgress),
|
|
|
|
finishedAt: progressPayload.finishedAt || null,
|
|
|
|
extraData: {
|
|
|
|
libraryItemId: progressPayload.libraryItemId,
|
|
|
|
progress: isNullOrNaN(progressPayload.progress) ? 0 : Number(progressPayload.progress)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (newMediaProgressPayload.isFinished) {
|
|
|
|
newMediaProgressPayload.finishedAt = new Date()
|
|
|
|
newMediaProgressPayload.extraData.progress = 1
|
|
|
|
} else {
|
|
|
|
newMediaProgressPayload.finishedAt = null
|
|
|
|
}
|
|
|
|
mediaProgress = await this.sequelize.models.mediaProgress.create(newMediaProgressPayload)
|
|
|
|
this.mediaProgresses.push(mediaProgress)
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
mediaProgress
|
|
|
|
}
|
|
|
|
}
|
2024-08-11 19:16:45 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Find bookmark
|
|
|
|
* TODO: Bookmarks should use mediaItemId instead of libraryItemId to support podcast episodes
|
|
|
|
*
|
|
|
|
* @param {string} libraryItemId
|
|
|
|
* @param {number} time
|
|
|
|
* @returns {AudioBookmarkObject|null}
|
|
|
|
*/
|
|
|
|
findBookmark(libraryItemId, time) {
|
|
|
|
return this.bookmarks.find((bm) => bm.libraryItemId === libraryItemId && bm.time == time)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Create bookmark
|
|
|
|
*
|
|
|
|
* @param {string} libraryItemId
|
|
|
|
* @param {number} time
|
|
|
|
* @param {string} title
|
|
|
|
* @returns {Promise<AudioBookmarkObject>}
|
|
|
|
*/
|
|
|
|
async createBookmark(libraryItemId, time, title) {
|
|
|
|
const existingBookmark = this.findBookmark(libraryItemId, time)
|
|
|
|
if (existingBookmark) {
|
|
|
|
Logger.warn('[User] Create Bookmark already exists for this time')
|
|
|
|
if (existingBookmark.title !== title) {
|
|
|
|
existingBookmark.title = title
|
|
|
|
this.changed('bookmarks', true)
|
|
|
|
await this.save()
|
|
|
|
}
|
|
|
|
return existingBookmark
|
|
|
|
}
|
|
|
|
|
|
|
|
const newBookmark = {
|
|
|
|
libraryItemId,
|
|
|
|
time,
|
|
|
|
title,
|
|
|
|
createdAt: Date.now()
|
|
|
|
}
|
|
|
|
this.bookmarks.push(newBookmark)
|
|
|
|
this.changed('bookmarks', true)
|
|
|
|
await this.save()
|
|
|
|
return newBookmark
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Update bookmark
|
|
|
|
*
|
|
|
|
* @param {string} libraryItemId
|
|
|
|
* @param {number} time
|
|
|
|
* @param {string} title
|
|
|
|
* @returns {Promise<AudioBookmarkObject>}
|
|
|
|
*/
|
|
|
|
async updateBookmark(libraryItemId, time, title) {
|
|
|
|
const bookmark = this.findBookmark(libraryItemId, time)
|
|
|
|
if (!bookmark) {
|
|
|
|
Logger.error(`[User] updateBookmark not found`)
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
bookmark.title = title
|
|
|
|
this.changed('bookmarks', true)
|
|
|
|
await this.save()
|
|
|
|
return bookmark
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Remove bookmark
|
|
|
|
*
|
|
|
|
* @param {string} libraryItemId
|
|
|
|
* @param {number} time
|
|
|
|
* @returns {Promise<boolean>} - true if bookmark was removed
|
|
|
|
*/
|
|
|
|
async removeBookmark(libraryItemId, time) {
|
|
|
|
if (!this.findBookmark(libraryItemId, time)) {
|
|
|
|
Logger.error(`[User] removeBookmark not found`)
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
this.bookmarks = this.bookmarks.filter((bm) => bm.libraryItemId !== libraryItemId || bm.time !== time)
|
|
|
|
this.changed('bookmarks', true)
|
|
|
|
await this.save()
|
|
|
|
return true
|
|
|
|
}
|
2024-08-11 20:09:53 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param {string} seriesId
|
|
|
|
* @returns {Promise<boolean>}
|
|
|
|
*/
|
|
|
|
async addSeriesToHideFromContinueListening(seriesId) {
|
|
|
|
if (!this.extraData) this.extraData = {}
|
|
|
|
const seriesHideFromContinueListening = this.extraData.seriesHideFromContinueListening || []
|
|
|
|
if (seriesHideFromContinueListening.includes(seriesId)) return false
|
|
|
|
seriesHideFromContinueListening.push(seriesId)
|
|
|
|
this.extraData.seriesHideFromContinueListening = seriesHideFromContinueListening
|
|
|
|
this.changed('extraData', true)
|
|
|
|
await this.save()
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @param {string} seriesId
|
|
|
|
* @returns {Promise<boolean>}
|
|
|
|
*/
|
|
|
|
async removeSeriesFromHideFromContinueListening(seriesId) {
|
|
|
|
if (!this.extraData) this.extraData = {}
|
|
|
|
let seriesHideFromContinueListening = this.extraData.seriesHideFromContinueListening || []
|
|
|
|
if (!seriesHideFromContinueListening.includes(seriesId)) return false
|
|
|
|
seriesHideFromContinueListening = seriesHideFromContinueListening.filter((sid) => sid !== seriesId)
|
|
|
|
this.extraData.seriesHideFromContinueListening = seriesHideFromContinueListening
|
|
|
|
this.changed('extraData', true)
|
|
|
|
await this.save()
|
|
|
|
return true
|
|
|
|
}
|
2024-08-11 23:07:29 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* Update user permissions from external JSON
|
|
|
|
*
|
|
|
|
* @param {Object} absPermissions JSON containing user permissions
|
|
|
|
* @returns {Promise<boolean>} true if updates were made
|
|
|
|
*/
|
|
|
|
async updatePermissionsFromExternalJSON(absPermissions) {
|
|
|
|
if (!this.permissions) this.permissions = {}
|
|
|
|
let hasUpdates = false
|
|
|
|
|
|
|
|
// Map the boolean permissions from absPermissions
|
|
|
|
Object.keys(absPermissions).forEach((absKey) => {
|
|
|
|
const userPermKey = User.permissionMapping[absKey]
|
|
|
|
if (!userPermKey) {
|
|
|
|
throw new Error(`Unexpected permission property: ${absKey}`)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!['librariesAccessible', 'itemTagsSelected'].includes(userPermKey)) {
|
|
|
|
if (this.permissions[userPermKey] !== !!absPermissions[absKey]) {
|
|
|
|
this.permissions[userPermKey] = !!absPermissions[absKey]
|
|
|
|
hasUpdates = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
|
|
|
|
// Handle allowedLibraries
|
|
|
|
const librariesAccessible = this.permissions.librariesAccessible || []
|
|
|
|
if (this.permissions.accessAllLibraries) {
|
|
|
|
if (librariesAccessible.length) {
|
|
|
|
this.permissions.librariesAccessible = []
|
|
|
|
hasUpdates = true
|
|
|
|
}
|
|
|
|
} else if (absPermissions.allowedLibraries?.length && absPermissions.allowedLibraries.join(',') !== librariesAccessible.join(',')) {
|
|
|
|
if (absPermissions.allowedLibraries.some((lid) => typeof lid !== 'string')) {
|
|
|
|
throw new Error('Invalid permission property "allowedLibraries", expecting array of strings')
|
|
|
|
}
|
|
|
|
this.permissions.librariesAccessible = absPermissions.allowedLibraries
|
|
|
|
hasUpdates = true
|
|
|
|
}
|
|
|
|
|
|
|
|
// Handle allowedTags
|
|
|
|
const itemTagsSelected = this.permissions.itemTagsSelected || []
|
|
|
|
if (this.permissions.accessAllTags) {
|
|
|
|
if (itemTagsSelected.length) {
|
|
|
|
this.permissions.itemTagsSelected = []
|
|
|
|
hasUpdates = true
|
|
|
|
}
|
|
|
|
} else if (absPermissions.allowedTags?.length && absPermissions.allowedTags.join(',') !== itemTagsSelected.join(',')) {
|
|
|
|
if (absPermissions.allowedTags.some((tag) => typeof tag !== 'string')) {
|
|
|
|
throw new Error('Invalid permission property "allowedTags", expecting array of strings')
|
|
|
|
}
|
|
|
|
this.permissions.itemTagsSelected = absPermissions.allowedTags
|
|
|
|
hasUpdates = true
|
|
|
|
}
|
|
|
|
|
|
|
|
if (hasUpdates) {
|
|
|
|
this.changed('permissions', true)
|
|
|
|
await this.save()
|
|
|
|
}
|
|
|
|
|
|
|
|
return hasUpdates
|
|
|
|
}
|
2023-08-16 23:38:48 +02:00
|
|
|
}
|
2023-07-05 01:14:44 +02:00
|
|
|
|
2024-05-29 00:24:02 +02:00
|
|
|
module.exports = User
|