mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-03 06:29:42 +00:00
Merge 633bc4805e into 6e0da3bf7a
This commit is contained in:
commit
c2062cdb0d
21 changed files with 1671 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
388
server/controllers/ReviewController.js
Normal file
388
server/controllers/ReviewController.js
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue