Created Rating and Review Feature as well as added a Top Rated books list to the Stats page

This commit is contained in:
fannta1990 2026-02-09 10:04:11 +01:00
parent b01facc034
commit 3a8075a077
15 changed files with 861 additions and 2 deletions

View file

@ -162,6 +162,11 @@ class Database {
return this.models.device
}
/** @type {typeof import('./models/Review')} */
get reviewModel() {
return this.models.review
}
/**
* Check if db file exists
* @returns {boolean}
@ -345,6 +350,7 @@ class Database {
require('./models/Setting').init(this.sequelize)
require('./models/CustomMetadataProvider').init(this.sequelize)
require('./models/MediaItemShare').init(this.sequelize)
require('./models/Review').init(this.sequelize)
return this.sequelize.sync({ force, alter: false })
}

View file

@ -995,6 +995,48 @@ class LibraryController {
stats.totalSize = bookStats.totalSize
stats.totalDuration = bookStats.totalDuration
stats.numAudioTracks = bookStats.numAudioFiles
// Get top 10 rated items
const topRatedReviews = await Database.reviewModel.findAll({
attributes: [
'libraryItemId',
[Sequelize.fn('AVG', Sequelize.col('rating')), 'avgRating'],
[Sequelize.fn('COUNT', Sequelize.col('id')), 'numReviews']
],
include: [
{
model: Database.libraryItemModel,
attributes: ['id'],
where: { libraryId: req.library.id },
include: [
{
model: Database.bookModel,
attributes: ['id'],
include: [
{
model: Database.bookMetadataModel,
attributes: ['title']
}
]
}
]
}
],
group: ['libraryItemId', 'libraryItem.id', 'libraryItem.book.id', 'libraryItem.book.bookMetadata.id'],
order: [
[Sequelize.literal('avgRating'), 'DESC'],
[Sequelize.literal('numReviews'), 'DESC']
],
limit: 10
})
stats.topRatedItems = topRatedReviews.map((r) => {
return {
id: r.libraryItemId,
title: r.libraryItem?.book?.bookMetadata?.title || 'Unknown',
avgRating: parseFloat(r.getDataValue('avgRating')),
numReviews: parseInt(r.getDataValue('numReviews'))
}
})
} else {
const genres = await libraryItemsPodcastFilters.getGenresWithCount(req.library.id)
const podcastStats = await libraryItemsPodcastFilters.getPodcastLibraryStats(req.library.id)

View file

@ -0,0 +1,171 @@
const Database = require('../Database')
const Logger = require('../Logger')
class ReviewController {
constructor() {}
/**
* POST: /api/items/:id/review
* Create or update the current user's review for a library item.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async createUpdate(req, res) {
const { rating, reviewText } = req.body
const libraryItemId = req.params.id
if (isNaN(rating) || rating < 1 || rating > 5) {
return res.status(400).send('Invalid rating. Must be an integer between 1 and 5.')
}
const cleanReviewText = reviewText ? String(reviewText).trim().substring(0, 5000) : null
try {
const [review, created] = await Database.reviewModel.findOrCreate({
where: {
userId: req.user.id,
libraryItemId
},
defaults: {
rating,
reviewText: cleanReviewText
}
})
if (!created) {
review.rating = rating
review.reviewText = cleanReviewText
await review.save()
}
// Load user for toOldJSON
review.user = req.user
res.json(review.toOldJSON())
} catch (error) {
Logger.error(`[ReviewController] Failed to create/update review`, error)
res.status(500).send('Failed to save review')
}
}
/**
* GET: /api/items/:id/reviews
* Get all reviews for a library item.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async findAllForItem(req, res) {
const libraryItemId = req.params.id
try {
const reviews = await Database.reviewModel.findAll({
where: { libraryItemId },
include: [
{
model: Database.userModel,
attributes: ['id', 'username']
}
],
order: [['createdAt', 'DESC']]
})
res.json(reviews.map((r) => r.toOldJSON()))
} catch (error) {
Logger.error(`[ReviewController] Failed to fetch reviews for item ${libraryItemId}`, error)
res.status(500).send('Failed to fetch reviews')
}
}
/**
* DELETE: /api/items/:id/review
* Delete the current user's review for a library item.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async delete(req, res) {
const libraryItemId = req.params.id
try {
const review = await Database.reviewModel.findOne({
where: {
userId: req.user.id,
libraryItemId
}
})
if (!review) {
return res.sendStatus(404)
}
await review.destroy()
res.sendStatus(200)
} catch (error) {
Logger.error(`[ReviewController] Failed to delete review for item ${libraryItemId}`, error)
res.status(500).send('Failed to delete review')
}
}
/**
* GET: /api/me/reviews
* Get all reviews by the current user.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async findAllForUser(req, res) {
try {
const reviews = await Database.reviewModel.findAll({
where: { userId: req.user.id },
include: [
{
model: Database.libraryItemModel,
include: [
{
model: Database.bookModel
},
{
model: Database.podcastModel
}
]
}
],
order: [['createdAt', 'DESC']]
})
res.json(reviews.map((r) => {
const json = r.toOldJSON()
if (r.libraryItem) {
json.libraryItem = r.libraryItem.toOldJSONMinified()
}
return json
}))
} catch (error) {
Logger.error(`[ReviewController] Failed to fetch reviews for user ${req.user.id}`, error)
res.status(500).send('Failed to fetch reviews')
}
}
/**
* Middleware for review routes.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async middleware(req, res, next) {
// Basic library item access check
req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
if (!req.libraryItem?.media) return res.sendStatus(404)
if (!req.user.checkCanAccessLibraryItem(req.libraryItem)) {
return res.sendStatus(403)
}
next()
}
}
module.exports = new ReviewController()

View file

@ -0,0 +1,110 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
const migrationVersion = '2.33.0'
const migrationName = `${migrationVersion}-create-reviews-table`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This upward migration creates a reviews table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
// Check if table exists
if (await queryInterface.tableExists('reviews')) {
logger.info(`${loggerPrefix} table "reviews" already exists`)
} else {
// Create table
logger.info(`${loggerPrefix} creating table "reviews"`)
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
await queryInterface.createTable('reviews', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
rating: {
type: DataTypes.INTEGER,
allowNull: false
},
reviewText: {
type: DataTypes.TEXT,
allowNull: true
},
createdAt: {
type: DataTypes.DATE,
allowNull: false
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false
},
userId: {
type: DataTypes.UUID,
references: {
model: {
tableName: 'users'
},
key: 'id'
},
allowNull: false,
onDelete: 'CASCADE'
},
libraryItemId: {
type: DataTypes.UUID,
references: {
model: {
tableName: 'libraryItems'
},
key: 'id'
},
allowNull: false,
onDelete: 'CASCADE'
}
})
// Add unique constraint on (userId, libraryItemId)
await queryInterface.addIndex('reviews', ['userId', 'libraryItemId'], {
unique: true,
name: 'reviews_user_id_library_item_id_unique'
})
logger.info(`${loggerPrefix} created table "reviews"`)
}
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This downward migration script removes the reviews table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
if (await queryInterface.tableExists('reviews')) {
logger.info(`${loggerPrefix} dropping table "reviews"`)
await queryInterface.dropTable('reviews')
logger.info(`${loggerPrefix} dropped table "reviews"`)
} else {
logger.info(`${loggerPrefix} table "reviews" does not exist`)
}
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }

82
server/models/Review.js Normal file
View file

@ -0,0 +1,82 @@
const { DataTypes, Model } = require('sequelize')
class Review extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {number} */
this.rating
/** @type {string} */
this.reviewText
/** @type {UUIDV4} */
this.userId
/** @type {UUIDV4} */
this.libraryItemId
/** @type {Date} */
this.updatedAt
/** @type {Date} */
this.createdAt
}
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
rating: {
type: DataTypes.INTEGER,
allowNull: false,
validate: {
min: 1,
max: 5
}
},
reviewText: {
type: DataTypes.TEXT,
allowNull: true
}
},
{
sequelize,
modelName: 'review',
indexes: [
{
unique: true,
fields: ['userId', 'libraryItemId']
}
]
}
)
const { user, libraryItem } = sequelize.models
user.hasMany(Review, { onDelete: 'CASCADE' })
Review.belongsTo(user)
libraryItem.hasMany(Review, { onDelete: 'CASCADE' })
Review.belongsTo(libraryItem)
}
toOldJSON() {
return {
id: this.id,
rating: this.rating,
reviewText: this.reviewText,
userId: this.userId,
libraryItemId: this.libraryItemId,
updatedAt: this.updatedAt.valueOf(),
createdAt: this.createdAt.valueOf(),
user: this.user ? {
id: this.user.id,
username: this.user.username
} : undefined
}
}
}
module.exports = Review

View file

@ -35,6 +35,7 @@ const MiscController = require('../controllers/MiscController')
const ShareController = require('../controllers/ShareController')
const StatsController = require('../controllers/StatsController')
const ApiKeyController = require('../controllers/ApiKeyController')
const ReviewController = require('../controllers/ReviewController')
class ApiRouter {
constructor(Server) {
@ -127,6 +128,10 @@ class ApiRouter {
this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this))
this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this))
this.router.get('/items/:id/reviews', ReviewController.middleware.bind(this), ReviewController.findAllForItem.bind(this))
this.router.post('/items/:id/review', ReviewController.middleware.bind(this), ReviewController.createUpdate.bind(this))
this.router.delete('/items/:id/review', ReviewController.middleware.bind(this), ReviewController.delete.bind(this))
//
// User Routes
//
@ -188,6 +193,7 @@ class ApiRouter {
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this))
this.router.post('/me/ereader-devices', MeController.updateUserEReaderDevices.bind(this))
this.router.get('/me/reviews', ReviewController.findAllForUser.bind(this))
//
// Backup Routes