Add review and rating features with sorting and filtering options

- Implemented a new ReviewController to handle review creation, updates, and retrieval for library items.
- Added pagination, sorting, and filtering capabilities for reviews in the API.
- Updated frontend components to support review display, including a new ReviewsTable and enhanced ratings UI.
- Introduced new strings for user interface elements related to reviews and ratings.
- Added tests for the ReviewController and Review model to ensure functionality and validation.
- Enabled the option to toggle the review feature in server settings.
This commit is contained in:
fannta1990 2026-02-09 21:08:18 +08:00
parent e4e2770fbd
commit 41e8906312
12 changed files with 603 additions and 43 deletions

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

@ -180,6 +180,110 @@ class ReviewController {
}
}
/**
* 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
})
res.json({
reviews: results,
total: count,
page: pageNum,
limit: limitNum
})
} 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.
*

View file

@ -1,18 +1,32 @@
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 {UUIDV4} */
/** @type {string} */
this.id
/** @type {number} */
this.rating
/** @type {string} */
this.reviewText
/** @type {UUIDV4} */
/** @type {string} */
this.userId
/** @type {UUIDV4} */
/** @type {string} */
this.libraryItemId
/** @type {Date} */
this.updatedAt
@ -20,6 +34,12 @@ class Review extends Model {
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(
{
@ -62,6 +82,11 @@ class Review extends Model {
Review.belongsTo(libraryItem)
}
/**
* Convert to the old JSON format for the browser.
*
* @returns {ReviewJSON}
*/
toOldJSON() {
return {
id: this.id,
@ -79,4 +104,4 @@ class Review extends Model {
}
}
module.exports = Review
module.exports = Review

View file

@ -55,7 +55,9 @@ 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

View file

@ -84,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))