This commit is contained in:
fannta1990 2026-02-22 12:40:47 -05:00 committed by GitHub
commit c2062cdb0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1671 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

@ -387,6 +387,7 @@ class Server {
'/library/:library/authors',
'/library/:library/narrators',
'/library/:library/stats',
'/library/:library/ratings',
'/library/:library/series/:id?',
'/library/:library/podcast/search',
'/library/:library/podcast/latest',

View file

@ -995,6 +995,47 @@ class LibraryController {
stats.totalSize = bookStats.totalSize
stats.totalDuration = bookStats.totalDuration
stats.numAudioTracks = bookStats.numAudioFiles
// Get top 10 rated items
try {
const topRatedReviews = await Database.reviewModel.findAll({
attributes: [
'libraryItemId',
[Sequelize.fn('AVG', Sequelize.col('review.rating')), 'avgRating'],
[Sequelize.fn('COUNT', Sequelize.col('review.id')), 'numReviews']
],
include: [
{
model: Database.libraryItemModel,
attributes: ['id'],
where: { libraryId: req.library.id },
include: [
{
model: Database.bookModel,
attributes: ['id', 'title']
}
]
}
],
group: ['libraryItemId', 'libraryItem.id', 'libraryItem.book.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?.title || 'Unknown',
avgRating: parseFloat(r.getDataValue('avgRating')),
numReviews: parseInt(r.getDataValue('numReviews'))
}
})
} catch (error) {
Logger.error('[LibraryController] Failed to get top rated items for stats', error)
stats.topRatedItems = []
}
} else {
const genres = await libraryItemsPodcastFilters.getGenresWithCount(req.library.id)
const podcastStats = await libraryItemsPodcastFilters.getPodcastLibraryStats(req.library.id)

View file

@ -0,0 +1,388 @@
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')
}
}
/**
* DELETE: /api/reviews/:id
* Delete a review by ID.
* Admin or the owner of the review can delete it.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async deleteById(req, res) {
if (!Database.serverSettings.enableReviews) {
return res.status(403).send('Review feature is disabled')
}
const { id } = req.params
try {
const review = await Database.reviewModel.findByPk(id)
if (!review) {
return res.sendStatus(404)
}
// Check if user is owner or admin
if (review.userId !== req.user.id && req.user.type !== 'admin' && req.user.type !== 'root') {
return res.status(403).send('Not authorized to delete this review')
}
await review.destroy()
res.sendStatus(200)
} catch (error) {
Logger.error(`[ReviewController] Failed to delete review ${id}`, 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) {
if (!Database.serverSettings.enableReviews) {
return res.status(403).send('Review feature is disabled')
}
try {
const reviews = await Database.reviewModel.findAll({
where: { userId: req.user.id },
include: [
{
model: Database.libraryItemModel,
include: [
{
model: Database.bookModel,
include: [
{
model: Database.authorModel,
through: { attributes: [] }
},
{
model: Database.seriesModel,
through: { attributes: ['id', 'sequence'] }
}
]
},
{
model: Database.podcastModel,
include: {
model: Database.podcastEpisodeModel
}
}
]
}
],
order: [['createdAt', 'DESC']]
})
res.json(reviews.map((r) => {
const json = r.toOldJSON()
if (r.libraryItem) {
// Manually set media if missing (Sequelize hooks don't run on nested includes)
if (!r.libraryItem.media) {
if (r.libraryItem.mediaType === 'book' && r.libraryItem.book) {
r.libraryItem.media = r.libraryItem.book
} else if (r.libraryItem.mediaType === 'podcast' && r.libraryItem.podcast) {
r.libraryItem.media = r.libraryItem.podcast
}
}
if (r.libraryItem.media) {
try {
json.libraryItem = r.libraryItem.toOldJSONMinified()
} catch (err) {
Logger.error(`[ReviewController] Failed to minify library item ${r.libraryItem.id}`, err)
}
}
}
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')
}
}
/**
* GET: /api/libraries/:id/reviews
* Get all reviews for items in a library.
* Supports sorting by newest, oldest, highest, lowest.
* Supports filtering by user or rating.
* Supports pagination with limit and page.
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async findAllForLibrary(req, res) {
if (!Database.serverSettings.enableReviews) {
return res.status(403).send('Review feature is disabled')
}
const libraryId = req.params.id
const { sort, filter, limit, page } = req.query
try {
const where = {}
const include = [
{
model: Database.userModel,
attributes: ['id', 'username']
},
{
model: Database.libraryItemModel,
where: { libraryId },
required: true,
include: [
{
model: Database.bookModel,
include: [
{ model: Database.authorModel, through: { attributes: [] } },
{ model: Database.seriesModel, through: { attributes: ['id', 'sequence'] } }
]
},
{
model: Database.podcastModel,
include: { model: Database.podcastEpisodeModel }
}
]
}
]
if (filter) {
const [filterType, filterValue] = filter.split('.')
if (filterType === 'user' && filterValue) {
where.userId = filterValue
} else if (filterType === 'rating' && filterValue) {
where.rating = filterValue
}
}
let order = [['createdAt', 'DESC']]
if (sort === 'oldest') order = [['createdAt', 'ASC']]
else if (sort === 'highest') order = [['rating', 'DESC'], ['createdAt', 'DESC']]
else if (sort === 'lowest') order = [['rating', 'ASC'], ['createdAt', 'DESC']]
const limitNum = limit ? parseInt(limit) : 50
const pageNum = page ? parseInt(page) : 0
const offset = pageNum * limitNum
const { count, rows: reviews } = await Database.reviewModel.findAndCountAll({
where,
include,
order,
limit: limitNum,
offset
})
const results = reviews.map((r) => {
const json = r.toOldJSON()
if (r.libraryItem) {
if (!r.libraryItem.media) {
if (r.libraryItem.mediaType === 'book' && r.libraryItem.book) {
r.libraryItem.media = r.libraryItem.book
} else if (r.libraryItem.mediaType === 'podcast' && r.libraryItem.podcast) {
r.libraryItem.media = r.libraryItem.podcast
}
}
if (r.libraryItem.media) {
try {
json.libraryItem = r.libraryItem.toOldJSONMinified()
} catch (err) {
Logger.error(`[ReviewController] Failed to minify library item ${r.libraryItem.id}`, err)
}
}
}
return json
})
// Collect unique reviewers for the filter dropdown
const allReviewers = await Database.reviewModel.findAll({
attributes: ['userId'],
include: [
{
model: Database.libraryItemModel,
where: { libraryId },
required: true,
attributes: []
},
{
model: Database.userModel,
attributes: ['id', 'username']
}
],
group: ['review.userId']
})
const reviewers = allReviewers
.filter((r) => r.user)
.map((r) => ({ id: r.user.id, username: r.user.username }))
.filter((v, i, a) => a.findIndex((t) => t.id === v.id) === i)
// Get counts for each rating level
const ratingCountsResults = await Database.reviewModel.findAll({
attributes: ['rating', [Database.sequelize.fn('COUNT', Database.sequelize.col('review.id')), 'count']],
include: [
{
model: Database.libraryItemModel,
where: { libraryId },
required: true,
attributes: []
}
],
group: ['rating']
})
const ratingCounts = {}
for (let i = 1; i <= 5; i++) ratingCounts[i] = 0
ratingCountsResults.forEach((r) => {
ratingCounts[r.rating] = parseInt(r.get('count'))
})
res.json({
reviews: results,
total: count,
page: pageNum,
limit: limitNum,
reviewers,
ratingCounts
})
} catch (error) {
Logger.error(`[ReviewController] Failed to fetch reviews for library ${libraryId}`, 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) {
if (!Database.serverSettings.enableReviews) {
return res.status(403).send('Review feature is disabled')
}
// 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 }

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

@ -0,0 +1,107 @@
const { DataTypes, Model } = require('sequelize')
/**
* @typedef ReviewJSON
* @property {string} id
* @property {number} rating
* @property {string} reviewText
* @property {string} userId
* @property {string} libraryItemId
* @property {number} updatedAt
* @property {number} createdAt
* @property {Object} [user]
* @property {string} user.id
* @property {string} user.username
*/
class Review extends Model {
constructor(values, options) {
super(values, options)
/** @type {string} */
this.id
/** @type {number} */
this.rating
/** @type {string} */
this.reviewText
/** @type {string} */
this.userId
/** @type {string} */
this.libraryItemId
/** @type {Date} */
this.updatedAt
/** @type {Date} */
this.createdAt
}
/**
* Initialize the Review model and associations.
* A user can have only one review per library item.
*
* @param {import('sequelize').Sequelize} sequelize
*/
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)
}
/**
* Convert to the old JSON format for the browser.
*
* @returns {ReviewJSON}
*/
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

@ -55,6 +55,11 @@ class ServerSettings {
this.language = 'en-us'
this.allowedOrigins = []
/** @type {boolean} If true, users can rate and review library items */
this.enableReviews = true
/** @type {boolean} If true, the Ratings page link is shown in the library sidebar */
this.showReviewsInSidebar = true
this.logLevel = Logger.logLevel
this.version = packageJson.version
@ -122,6 +127,9 @@ class ServerSettings {
this.timeFormat = settings.timeFormat || 'HH:mm'
this.language = settings.language || 'en-us'
this.allowedOrigins = settings.allowedOrigins || []
this.enableReviews = settings.enableReviews !== false
this.showReviewsInSidebar = settings.showReviewsInSidebar !== false
this.logLevel = settings.logLevel || Logger.logLevel
this.version = settings.version || null
this.buildNumber = settings.buildNumber || 0 // Added v2.4.5
@ -234,6 +242,8 @@ class ServerSettings {
timeFormat: this.timeFormat,
language: this.language,
allowedOrigins: this.allowedOrigins,
enableReviews: this.enableReviews,
showReviewsInSidebar: this.showReviewsInSidebar,
logLevel: this.logLevel,
version: this.version,
buildNumber: this.buildNumber,

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) {
@ -83,6 +84,7 @@ class ApiRouter {
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
this.router.get('/libraries/:id/reviews', LibraryController.middleware.bind(this), ReviewController.findAllForLibrary.bind(this))
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this))
this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this))
@ -127,6 +129,11 @@ 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))
this.router.delete('/reviews/:id', ReviewController.deleteById.bind(this))
//
// User Routes
//
@ -188,6 +195,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