Add favorite property for items and associated filters.

This commit is contained in:
Rapha149 2026-03-16 19:46:45 +01:00
parent 6d3773a0b8
commit a5999fb9df
14 changed files with 308 additions and 11 deletions

View file

@ -102,6 +102,11 @@ class Database {
return this.models.libraryItem
}
/** @type {typeof import('./models/UserFavorite')} */
get userFavoriteModel() {
return this.models.userFavorite
}
/** @type {typeof import('./models/PodcastEpisode')} */
get podcastEpisodeModel() {
return this.models.podcastEpisode
@ -329,6 +334,7 @@ class Database {
require('./models/Podcast').init(this.sequelize)
require('./models/PodcastEpisode').init(this.sequelize)
require('./models/LibraryItem').init(this.sequelize)
require('./models/UserFavorite').init(this.sequelize)
require('./models/MediaProgress').init(this.sequelize)
require('./models/Series').init(this.sequelize)
require('./models/BookSeries').init(this.sequelize)

View file

@ -198,6 +198,67 @@ class MeController {
res.sendStatus(200)
}
/**
* POST: /api/me/item/:id/favorite
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async addFavorite(req, res) {
const libraryItemId = req.params.id
await Database.userFavoriteModel.create({
userId: req.user.id,
libraryItemId
})
// Reload favorites for the user
await req.user.reload({
include: [
Database.mediaProgressModel,
{
model: Database.libraryItemModel,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.sendStatus(200)
}
/**
* DELETE: /api/me/item/:id/favorite
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async removeFavorite(req, res) {
const libraryItemId = req.params.id
await Database.userFavoriteModel.destroy({
where: { userId: req.user.id, libraryItemId }
})
// Reload favorites for the user
await req.user.reload({
include: [
Database.mediaProgressModel,
{
model: Database.libraryItemModel,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.sendStatus(200)
}
/**
* POST: /api/me/item/:id/bookmark
*

View file

@ -114,6 +114,8 @@ class User extends Model {
this.updatedAt
/** @type {import('./MediaProgress')[]?} - Only included when extended */
this.mediaProgresses
/** @type {import('./LibraryItem')[]?} - Only included when extended */
this.favorites
}
// Excludes "root" since their can only be 1 root user
@ -357,7 +359,15 @@ class User extends Model {
[sequelize.Op.like]: username
}
},
include: this.sequelize.models.mediaProgress
include: [
this.sequelize.models.mediaProgress,
{
model: this.sequelize.models.libraryItem,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
if (user) userCache.set(user)
@ -382,7 +392,15 @@ class User extends Model {
[sequelize.Op.like]: email
}
},
include: this.sequelize.models.mediaProgress
include: [
this.sequelize.models.mediaProgress,
{
model: this.sequelize.models.libraryItem,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
if (user) userCache.set(user)
@ -402,7 +420,15 @@ class User extends Model {
if (cachedUser) return cachedUser
const user = await this.findByPk(userId, {
include: this.sequelize.models.mediaProgress
include: [
this.sequelize.models.mediaProgress,
{
model: this.sequelize.models.libraryItem,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
if (user) userCache.set(user)
@ -426,7 +452,15 @@ class User extends Model {
where: {
[sequelize.Op.or]: [{ id: userId }, { 'extraData.oldUserId': userId }]
},
include: this.sequelize.models.mediaProgress
include: [
this.sequelize.models.mediaProgress,
{
model: this.sequelize.models.libraryItem,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
if (user) userCache.set(user)
@ -447,7 +481,15 @@ class User extends Model {
const user = await this.findOne({
where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub),
include: this.sequelize.models.mediaProgress
include: [
this.sequelize.models.mediaProgress,
{
model: this.sequelize.models.libraryItem,
as: 'favorites',
attributes: ['id'],
through: { attributes: [] }
}
]
})
if (user) userCache.set(user)
@ -621,6 +663,7 @@ class User extends Model {
isOldToken: this.isOldToken,
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
seriesHideFromContinueListening: [...seriesHideFromContinueListening],
favorites: this.favorites?.map(f => f.id) || [],
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],
isActive: this.isActive,
isLocked: this.isLocked,

View file

@ -0,0 +1,66 @@
const { DataTypes, Model } = require('sequelize')
class UserFavorite extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {UUIDV4} */
this.libraryItemId
/** @type {UUIDV4} */
this.userId
}
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
libraryItemId: DataTypes.UUID,
userId: DataTypes.UUID
},
{
sequelize,
modelName: 'userFavorite',
indexes: [
{
fields: ['userId']
},
{
fields: ['libraryItemId']
},
{
unique: true,
fields: ['libraryItemId', 'userId'],
}
]
}
)
const { libraryItem, user } = sequelize.models
libraryItem.hasMany(UserFavorite, {
foreignKey: 'libraryItemId',
onDelete: 'CASCADE'
})
UserFavorite.belongsTo(libraryItem, { foreignKey: 'libraryItemId' })
user.hasMany(UserFavorite, {
foreignKey: 'userId',
onDelete: 'CASCADE'
})
user.belongsToMany(libraryItem, {
through: UserFavorite,
foreignKey: 'userId',
otherKey: 'libraryItemId',
as: 'favorites'
})
UserFavorite.belongsTo(user, { foreignKey: 'userId' })
}
}
module.exports = UserFavorite

View file

@ -179,6 +179,8 @@ class ApiRouter {
this.router.patch('/me/progress/batch/update', MeController.batchUpdateMediaProgress.bind(this))
this.router.patch('/me/progress/:libraryItemId/:episodeId?', MeController.createUpdateMediaProgress.bind(this))
this.router.delete('/me/progress/:id', MeController.removeMediaProgress.bind(this))
this.router.post('/me/item/:id/favorite', MeController.addFavorite.bind(this))
this.router.delete('/me/item/:id/favorite', MeController.removeFavorite.bind(this))
this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this))
this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this))

View file

@ -106,7 +106,11 @@ module.exports = {
let mediaWhere = {}
const replacements = {}
if (group === 'progress') {
if (group === 'favorite') {
mediaWhere['$libraryItem.userFavorites.userId$'] = {
[Sequelize.Op.not]: null
}
} else if (group === 'progress') {
if (value === 'not-finished') {
mediaWhere['$mediaProgresses.isFinished$'] = {
[Sequelize.Op.or]: [null, false]
@ -531,6 +535,15 @@ module.exports = {
libraryItemWhere['createdAt'] = {
[Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago
}
} else if (filterGroup === 'favorite' && user) {
libraryItemIncludes.push({
model: Database.userFavoriteModel,
attributes: ['userId'],
where: {
userId: user.id
},
required: true
})
}
// When sorting by progress but not filtering by progress, include media progresses

View file

@ -52,7 +52,11 @@ module.exports = {
let mediaWhere = {}
const replacements = {}
if (['genres', 'tags'].includes(group)) {
if (group === 'favorite') {
mediaWhere['$libraryItem.userFavorites.userId$'] = {
[Sequelize.Op.not]: null
}
} else if (['genres', 'tags'].includes(group)) {
mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), {
[Sequelize.Op.gte]: 1
})
@ -172,6 +176,15 @@ module.exports = {
libraryItemWhere['createdAt'] = {
[Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago
}
} else if (filterGroup === 'favorite' && user) {
libraryItemIncludes.push({
model: Database.userFavoriteModel,
attributes: ['userId'],
where: {
userId: user.id
},
required: true
})
}
const podcastIncludes = []