From 3a8075a0779b9cd6f77bccb9dd0e47243272b000 Mon Sep 17 00:00:00 2001 From: fannta1990 Date: Mon, 9 Feb 2026 10:04:11 +0100 Subject: [PATCH] Created Rating and Review Feature as well as added a Top Rated books list to the Stats page --- client/components/app/SideRail.vue | 11 ++ client/components/modals/ReviewModal.vue | 99 ++++++++++ client/components/tables/ReviewsTable.vue | 116 ++++++++++++ client/components/ui/StarRating.vue | 55 ++++++ client/pages/item/_id/index.vue | 6 + client/pages/library/_library/ratings.vue | 109 +++++++++++ client/pages/library/_library/stats.vue | 23 +++ client/store/globals.js | 16 +- client/strings/en-us.json | 11 ++ server/Database.js | 6 + server/controllers/LibraryController.js | 42 +++++ server/controllers/ReviewController.js | 171 ++++++++++++++++++ .../v2.33.0-create-reviews-table.js | 110 +++++++++++ server/models/Review.js | 82 +++++++++ server/routers/ApiRouter.js | 6 + 15 files changed, 861 insertions(+), 2 deletions(-) create mode 100644 client/components/modals/ReviewModal.vue create mode 100644 client/components/tables/ReviewsTable.vue create mode 100644 client/components/ui/StarRating.vue create mode 100644 client/pages/library/_library/ratings.vue create mode 100644 server/controllers/ReviewController.js create mode 100644 server/migrations/v2.33.0-create-reviews-table.js create mode 100644 server/models/Review.js diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue index 9fa7661a1..7aac5a7c8 100644 --- a/client/components/app/SideRail.vue +++ b/client/components/app/SideRail.vue @@ -68,6 +68,14 @@
+ + star + +

{{ $strings.ButtonRatings }}

+ +
+ + @@ -174,6 +182,9 @@ export default { isNarratorsPage() { return this.$route.name === 'library-library-narrators' }, + isRatingsPage() { + return this.$route.name === 'library-library-ratings' + }, isPlaylistsPage() { return this.paramId === 'playlists' }, diff --git a/client/components/modals/ReviewModal.vue b/client/components/modals/ReviewModal.vue new file mode 100644 index 000000000..fd063a781 --- /dev/null +++ b/client/components/modals/ReviewModal.vue @@ -0,0 +1,99 @@ + + + diff --git a/client/components/tables/ReviewsTable.vue b/client/components/tables/ReviewsTable.vue new file mode 100644 index 000000000..fb3f73f5c --- /dev/null +++ b/client/components/tables/ReviewsTable.vue @@ -0,0 +1,116 @@ + + + diff --git a/client/components/ui/StarRating.vue b/client/components/ui/StarRating.vue new file mode 100644 index 000000000..67b30c55a --- /dev/null +++ b/client/components/ui/StarRating.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 1d8f0f20b..1ebb9dd0a 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -128,6 +128,8 @@
+ + @@ -143,6 +145,7 @@ +
@@ -434,6 +437,9 @@ export default { } }, methods: { + onReviewUpdated(review) { + this.$root.$emit('review-updated', review) + }, selectBookmark(bookmark) { if (!bookmark) return if (this.isStreaming) { diff --git a/client/pages/library/_library/ratings.vue b/client/pages/library/_library/ratings.vue new file mode 100644 index 000000000..779e74ec1 --- /dev/null +++ b/client/pages/library/_library/ratings.vue @@ -0,0 +1,109 @@ + + + diff --git a/client/pages/library/_library/stats.vue b/client/pages/library/_library/stats.vue index 0fcc2a414..40caec360 100644 --- a/client/pages/library/_library/stats.vue +++ b/client/pages/library/_library/stats.vue @@ -43,6 +43,26 @@ +
+

{{ $strings.HeaderStatsTopRated }}

+ +

{{ $strings.HeaderStatsLongestItems }}

{{ $strings.MessageNoItems }}

@@ -154,6 +174,9 @@ export default { top10Authors() { return this.authorsWithCount?.slice(0, 10) || [] }, + top10RatedItems() { + return this.libraryStats?.topRatedItems || [] + }, currentLibraryId() { return this.$store.state.libraries.currentLibraryId }, diff --git a/client/store/globals.js b/client/store/globals.js index 7b416196a..d9fc8a96e 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -25,8 +25,10 @@ export const state = () => ({ selectedRawCoverUrl: null, selectedMediaItemShare: null, isCasting: false, // Actively casting - isChromecastInitialized: false, // Script loadeds - showBatchQuickMatchModal: false, + isChromecastInitialized: false, // Script loadeds + showReviewModal: false, + selectedReviewItem: null, + showBatchQuickMatchModal: false, dateFormats: [ { text: 'MM/DD/YYYY', @@ -204,6 +206,16 @@ export const mutations = { setShowBatchQuickMatchModal(state, val) { state.showBatchQuickMatchModal = val }, + setShowReviewModal(state, val) { + state.showReviewModal = val + }, + setReviewModal(state, { libraryItem, review }) { + state.selectedReviewItem = { + libraryItem, + review + } + state.showReviewModal = true + }, resetSelectedMediaItems(state) { state.selectedMediaItems = [] }, diff --git a/client/strings/en-us.json b/client/strings/en-us.json index fb2bcb281..e63d83011 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -73,10 +73,13 @@ "ButtonQuickEmbed": "Quick Embed", "ButtonQuickEmbedMetadata": "Quick Embed Metadata", "ButtonQuickMatch": "Quick Match", + "ButtonRatings": "Ratings", "ButtonReScan": "Re-Scan", "ButtonRead": "Read", "ButtonReadLess": "Read less", "ButtonReadMore": "Read more", + "ButtonReviewEdit": "Edit Review", + "ButtonReviewWrite": "Write a Review", "ButtonRefresh": "Refresh", "ButtonRemove": "Remove", "ButtonRemoveAll": "Remove All", @@ -208,6 +211,7 @@ "HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)", "HeaderStatsRecentSessions": "Recent Sessions", "HeaderStatsTop10Authors": "Top 10 Authors", + "HeaderStatsTopRated": "Top 10 Best Rated", "HeaderStatsTop5Genres": "Top 5 Genres", "HeaderTableOfContents": "Table of Contents", "HeaderTools": "Tools", @@ -255,6 +259,7 @@ "LabelAuthorFirstLast": "Author (First Last)", "LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthors": "Authors", + "LabelAverageRating": "Average Rating", "LabelAutoDownloadEpisodes": "Auto Download Episodes", "LabelAutoFetchMetadata": "Auto Fetch Metadata", "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", @@ -481,6 +486,7 @@ "LabelNextScheduledRun": "Next scheduled run", "LabelNoApiKeys": "No API keys", "LabelNoCustomMetadataProviders": "No custom metadata providers", + "LabelNoReviews": "No reviews yet.", "LabelNoEpisodesSelected": "No episodes selected", "LabelNotFinished": "Not Finished", "LabelNotStarted": "Not Started", @@ -550,6 +556,7 @@ "LabelReadAgain": "Read Again", "LabelReadEbookWithoutProgress": "Read ebook without keeping progress", "LabelRecentSeries": "Recent Series", + "LabelRating": "Rating", "LabelRecentlyAdded": "Recently Added", "LabelRecommended": "Recommended", "LabelRedo": "Redo", @@ -561,6 +568,8 @@ "LabelRemoveCover": "Remove cover", "LabelRemoveMetadataFile": "Remove metadata files in library item folders", "LabelRemoveMetadataFileHelp": "Remove all metadata.json and metadata.abs files in your {0} folders.", + "LabelReviewComment": "Comment", + "LabelReviews": "Reviews", "LabelRowsPerPage": "Rows per page", "LabelSearchTerm": "Search Term", "LabelSearchTitle": "Search Title", @@ -816,6 +825,7 @@ "MessageFeedURLWillBe": "Feed URL will be {0}", "MessageFetching": "Fetching...", "MessageForceReScanDescription": "will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be scanned as new.", + "MessageGoRateBooks": "Go rate some books to see them here!", "MessageHeatmapListeningTimeTooltip": "{0} listening on {1}", "MessageHeatmapNoListeningSessions": "No listening sessions on {0}", "MessageImportantNotice": "Important Notice!", @@ -963,6 +973,7 @@ "PlaceholderNewPlaylist": "New playlist name", "PlaceholderSearch": "Search..", "PlaceholderSearchEpisode": "Search episode..", + "PlaceholderReviewWrite": "Write a personal comment...", "StatsAuthorsAdded": "authors added", "StatsBooksAdded": "books added", "StatsBooksAdditional": "Some additions include…", diff --git a/server/Database.js b/server/Database.js index 213c2c61b..0054e6be1 100644 --- a/server/Database.js +++ b/server/Database.js @@ -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 }) } diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 55ef45690..10113444c 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -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) diff --git a/server/controllers/ReviewController.js b/server/controllers/ReviewController.js new file mode 100644 index 000000000..4f34533b4 --- /dev/null +++ b/server/controllers/ReviewController.js @@ -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() diff --git a/server/migrations/v2.33.0-create-reviews-table.js b/server/migrations/v2.33.0-create-reviews-table.js new file mode 100644 index 000000000..e9198cd83 --- /dev/null +++ b/server/migrations/v2.33.0-create-reviews-table.js @@ -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} - 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} - 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 } diff --git a/server/models/Review.js b/server/models/Review.js new file mode 100644 index 000000000..e1b3c80e3 --- /dev/null +++ b/server/models/Review.js @@ -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 diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db04bf5ec..57cedb865 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -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