diff --git a/.vscode/settings.json b/.vscode/settings.json index 75503e6a4..bcfecf743 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,5 +23,8 @@ }, "[vue]": { "editor.defaultFormatter": "octref.vetur" + }, + "[javascript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" } } \ No newline at end of file diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue index 9fa7661a1..c7b7127ef 100644 --- a/client/components/app/SideRail.vue +++ b/client/components/app/SideRail.vue @@ -68,6 +68,14 @@
+{{ $strings.ButtonRatings }}
+ + +{{ $strings.LabelRating }}
+{{ reviewText.length }}/5000
+{{ $strings.LabelReviews }}
+ +{{ review.user.username }}
+{{ $formatDate(review.createdAt, dateFormat) }}
+ +{{ review.reviewText }}
++ {{ $formatNumber(totalReviews) }} {{ $strings.ButtonRatings }} +
+ + + + +{{ $strings.LabelNoReviews }}
+{{ $strings.MessageGoRateBooks }}
++ "{{ review.reviewText }}" +
+
+ {{ index + 1 }}.
{{ ab.avgRating.toFixed(1) }}
+ star +{{ ab.numReviews }} {{ $strings.LabelReviews.toLowerCase() }}
+{{ $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 }, @@ -165,6 +188,9 @@ export default { }, isBookLibrary() { return this.currentLibraryMediaType === 'book' + }, + enableReviews() { + return this.$store.getters['getServerSetting']('enableReviews') } }, methods: { 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..35fe7a83e 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", @@ -237,10 +241,19 @@ "LabelAddedDate": "Added {0}", "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", + "LabelAllReviews": "All Reviews", "LabelAllEpisodesDownloaded": "All episodes downloaded", "LabelAllUsers": "All Users", "LabelAllUsersExcludingGuests": "All users excluding guests", "LabelAllUsersIncludingGuests": "All users including guests", + "LabelFilterByRating": "Filter by Rating", + "LabelFilterByUser": "Filter by User", + "LabelSortHighestRated": "Highest Rated", + "LabelSortLowestRated": "Lowest Rated", + "LabelSortNewestFirst": "Newest First", + "LabelSortOldestFirst": "Oldest First", + "LabelSortTitleAZ": "Title A-Z", + "PlaceholderSearchReviews": "Search by title or author...", "LabelAlreadyInYourLibrary": "Already in your library", "LabelApiKeyCreated": "API Key \"{0}\" created successfully.", "LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.", @@ -255,6 +268,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 +495,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 +565,7 @@ "LabelReadAgain": "Read Again", "LabelReadEbookWithoutProgress": "Read ebook without keeping progress", "LabelRecentSeries": "Recent Series", + "LabelRating": "Rating", "LabelRecentlyAdded": "Recently Added", "LabelRecommended": "Recommended", "LabelRedo": "Redo", @@ -561,6 +577,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", @@ -591,6 +609,10 @@ "LabelSettingsEnableWatcher": "Automatically watch libraries for changes", "LabelSettingsEnableWatcherForLibrary": "Automatically watch library for changes", "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart", + "LabelSettingsEnableReviews": "Enable Reviews", + "LabelSettingsEnableReviewsHelp": "Allow users to rate and review books", + "LabelSettingsShowRatingsPage": "Show Ratings Page in Sidebar", + "LabelSettingsShowRatingsPageHelp": "Display the Ratings page link in the library sidebar", "LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs", "LabelSettingsEpubsAllowScriptedContentHelp": "Allow epub files to execute scripts. It is recommended to keep this setting disabled unless you trust the source of the epub files.", "LabelSettingsExperimentalFeatures": "Experimental features", @@ -816,6 +838,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 +986,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/Server.js b/server/Server.js index d6f748a1e..201e99cc7 100644 --- a/server/Server.js +++ b/server/Server.js @@ -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', diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 55ef45690..ccfb1188b 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -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) diff --git a/server/controllers/ReviewController.js b/server/controllers/ReviewController.js new file mode 100644 index 000000000..78a88fe86 --- /dev/null +++ b/server/controllers/ReviewController.js @@ -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() 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