mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-04 06:59:41 +00:00
Created Rating and Review Feature as well as added a Top Rated books list to the Stats page
This commit is contained in:
parent
b01facc034
commit
3a8075a077
15 changed files with 861 additions and 2 deletions
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
171
server/controllers/ReviewController.js
Normal file
171
server/controllers/ReviewController.js
Normal 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()
|
||||
110
server/migrations/v2.33.0-create-reviews-table.js
Normal file
110
server/migrations/v2.33.0-create-reviews-table.js
Normal 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
82
server/models/Review.js
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue