mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-03 22:49:42 +00:00
Merge 633bc4805e into 6e0da3bf7a
This commit is contained in:
commit
c2062cdb0d
21 changed files with 1671 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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
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 }
|
||||
107
server/models/Review.js
Normal file
107
server/models/Review.js
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue