diff --git a/Dockerfile b/Dockerfile index 816bdd3c3..ae08c823d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ FROM node:20-alpine AS build-client WORKDIR /client COPY /client /client -RUN npm ci && npm cache clean --force +RUN npm install && npm cache clean --force RUN npm run generate ### STAGE 1: Build server ### diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index fbb50bb14..9926acc17 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -78,7 +78,7 @@ - +
priority_high
@@ -137,7 +137,7 @@ \ No newline at end of file diff --git a/client/components/ui/RatingInput.vue b/client/components/ui/RatingInput.vue new file mode 100644 index 000000000..4c5e4a76b --- /dev/null +++ b/client/components/ui/RatingInput.vue @@ -0,0 +1,114 @@ + + + + + \ No newline at end of file diff --git a/client/components/widgets/BookDetailsEdit.vue b/client/components/widgets/BookDetailsEdit.vue index db3b86ed6..7747b62bb 100644 --- a/client/components/widgets/BookDetailsEdit.vue +++ b/client/components/widgets/BookDetailsEdit.vue @@ -50,20 +50,33 @@
-
+
-
+
-
-
- +
+ +
+
+
+
+
+ +
+
+
+
+ +
+
-
-
- +
+
+ +
@@ -72,7 +85,12 @@ diff --git a/client/plugins/init.client.js b/client/plugins/init.client.js index 015cd919d..8a0e5e6d2 100644 --- a/client/plugins/init.client.js +++ b/client/plugins/init.client.js @@ -1,5 +1,5 @@ import Vue from 'vue' -import Path from 'path' +import Path from 'path-browserify' import vClickOutside from 'v-click-outside' import { formatDistance, format, addDays, isDate, setDefaultOptions } from 'date-fns' import * as locale from 'date-fns/locale' @@ -61,44 +61,36 @@ Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => { const MAX_FILENAME_BYTES = 255 const replacement = '' - const illegalRe = /[\/\?<>\\:\*\|"]/g + const illegalRe = /[\\\?<>\\:\*\|\"]/g const controlRe = /[\x00-\x1f\x80-\x9f]/g const reservedRe = /^\.+$/ const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i const windowsTrailingRe = /[\. ]+$/ const lineBreaks = /[\n\r]/g - let sanitized = filename - .replace(':', colonReplacement) // Replace first occurrence of a colon - .replace(illegalRe, replacement) - .replace(controlRe, replacement) - .replace(reservedRe, replacement) - .replace(lineBreaks, replacement) - .replace(windowsReservedRe, replacement) - .replace(windowsTrailingRe, replacement) - .replace(/\s+/g, ' ') // Replace consecutive spaces with a single space + let sanitized = filename.replace(':', colonReplacement).replace(illegalRe, replacement).replace(controlRe, replacement).replace(reservedRe, replacement).replace(lineBreaks, replacement).replace(windowsReservedRe, replacement).replace(windowsTrailingRe, replacement).replace(/\s+/g, ' ') - // Check if basename is too many bytes const ext = Path.extname(sanitized) // separate out file extension const basename = Path.basename(sanitized, ext) - const extByteLength = Buffer.byteLength(ext, 'utf16le') - const basenameByteLength = Buffer.byteLength(basename, 'utf16le') - if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) { - const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength - let totalBytes = 0 - let trimmedBasename = '' + if (typeof Buffer !== 'undefined') { + const extByteLength = Buffer.byteLength(ext, 'utf16le') + const basenameByteLength = Buffer.byteLength(basename, 'utf16le') + if (basenameByteLength + extByteLength > MAX_FILENAME_BYTES) { + const MaxBytesForBasename = MAX_FILENAME_BYTES - extByteLength + let totalBytes = 0 + let trimmedBasename = '' - // Add chars until max bytes is reached - for (const char of basename) { - totalBytes += Buffer.byteLength(char, 'utf16le') - if (totalBytes > MaxBytesForBasename) break - else trimmedBasename += char + // Add chars until max bytes is reached + for (const char of basename) { + totalBytes += Buffer.byteLength(char, 'utf16le') + if (totalBytes > MaxBytesForBasename) break + else trimmedBasename += char + } + + trimmedBasename = trimmedBasename.trim() + sanitized = trimmedBasename + ext } - - trimmedBasename = trimmedBasename.trim() - sanitized = trimmedBasename + ext } - return sanitized } @@ -167,9 +159,31 @@ function xmlToJson(xml) { } Vue.prototype.$xmlToJson = xmlToJson -const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64')) +// Polyfilled Base64 encode/decode for browser environment +function utf8ToBase64(str) { + try { + return btoa(unescape(encodeURIComponent(str))) + } catch (e) { + return btoa(str) + } +} +function base64ToUtf8(str) { + try { + return decodeURIComponent(escape(atob(str))) + } catch (e) { + return atob(str) + } +} +const encode = (text) => { + const base64 = typeof Buffer !== 'undefined' && Buffer.from ? Buffer.from(text).toString('base64') : utf8ToBase64(text) + return encodeURIComponent(base64) +} Vue.prototype.$encode = encode -const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString() +const decode = (text) => { + const base64 = decodeURIComponent(text) + const decoded = typeof Buffer !== 'undefined' && Buffer.from ? Buffer.from(base64, 'base64').toString() : base64ToUtf8(base64) + return decoded +} Vue.prototype.$decode = decode export { encode, decode } diff --git a/client/static/audible.svg b/client/static/audible.svg new file mode 100644 index 000000000..7a71a01e0 --- /dev/null +++ b/client/static/audible.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/client/static/flame-icon.svg b/client/static/flame-icon.svg new file mode 100644 index 000000000..fa7a8ea7b --- /dev/null +++ b/client/static/flame-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/store/libraries.js b/client/store/libraries.js index 62c515ebf..40ceae68e 100644 --- a/client/store/libraries.js +++ b/client/store/libraries.js @@ -1,3 +1,4 @@ +import Vue from 'vue' const { Constants } = require('../plugins/constants') export const state = () => ({ @@ -12,7 +13,8 @@ export const state = () => ({ numUserPlaylists: 0, collections: [], userPlaylists: [], - ereaderDevices: [] + ereaderDevices: [], + libraryItemsCache: {} }) export const getters = { @@ -170,6 +172,15 @@ export const actions = { } export const mutations = { + UPDATE_LIBRARY_ITEM(state, libraryItem) { + const existingItem = state.libraryItemsCache[libraryItem.id] + if (existingItem) { + const updatedItem = { ...existingItem, ...libraryItem } + Vue.set(state.libraryItemsCache, libraryItem.id, updatedItem) + } else { + Vue.set(state.libraryItemsCache, libraryItem.id, libraryItem) + } + }, setFolders(state, folders) { state.folders = folders }, diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 42832e37a..cac3f11e0 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -199,6 +199,7 @@ "HeaderSettingsDisplay": "Display", "HeaderSettingsExperimental": "Experimental Features", "HeaderSettingsGeneral": "General", + "HeaderSettingsRatings": "Book Ratings", "HeaderSettingsScanner": "Scanner", "HeaderSettingsSecurity": "Security", "HeaderSettingsWebClient": "Web Client", @@ -366,6 +367,7 @@ "LabelExplicit": "Explicit", "LabelExplicitChecked": "Explicit (checked)", "LabelExplicitUnchecked": "Not Explicit (unchecked)", + "LabelExplicitRating": "Explicit Rating", "LabelExportOPML": "Export OPML", "LabelFeedURL": "Feed URL", "LabelFetchingMetadata": "Fetching Metadata", @@ -544,6 +546,7 @@ "LabelRSSFeedPreventIndexing": "Prevent Indexing", "LabelRSSFeedSlug": "RSS Feed Slug", "LabelRSSFeedURL": "RSS Feed URL", + "LabelRating": "Rating", "LabelRandomly": "Randomly", "LabelReAddSeriesToContinueListening": "Re-add series to Continue Listening", "LabelRead": "Read", @@ -588,7 +591,13 @@ "LabelSettingsBookshelfViewHelp": "Skeumorphic design with wooden shelves", "LabelSettingsChromecastSupport": "Chromecast support", "LabelSettingsDateFormat": "Date Format", - "LabelSettingsEnableWatcher": "Automatically scan libraries for changes", + "LabelSettingsEnableCommunityRating": "Enable Community Rating", + "LabelSettingsEnableCommunityRatingHelp": "Shows the community rating next to the user's personal rating.", + "LabelSettingsEnableExplicitRating": "Enable Explicit Rating", + "LabelSettingsEnableExplicitRatingHelp": "Enables a separate rating system for explicit books.", + "LabelSettingsEnableRating": "Enable Rating", + "LabelSettingsEnableRatingHelp": "Enables the rating system for all users.", + "LabelSettingsEnableWatcher": "Enable Watcher", "LabelSettingsEnableWatcherForLibrary": "Automatically scan library for changes", "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart", "LabelSettingsEpubsAllowScriptedContent": "Allow scripted content in epubs", diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 000000000..6bc6299cd --- /dev/null +++ b/client/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue2' +import path from 'path' +// Minimal Vite config for Cypress component testing +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': path.resolve(__dirname) + } + } +}) diff --git a/docker-compose.yml b/docker-compose.yml index b8d428a23..df6646c11 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,8 @@ ### EXAMPLE DOCKER COMPOSE ### services: audiobookshelf: - image: ghcr.io/advplyr/audiobookshelf:latest + #image: ghcr.io/advplyr/audiobookshelf:latest + build: . # ABS runs on port 13378 by default. If you want to change # the port, only change the external port, not the internal port ports: diff --git a/package-lock.json b/package-lock.json index 99415bd9c..a37daa5e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,8 @@ "mocha": "^10.2.0", "nodemon": "^2.0.20", "nyc": "^15.1.0", - "sinon": "^17.0.1" + "sinon": "^17.0.1", + "sinon-express-mock": "^2.2.1" } }, "node_modules/@ampproject/remapping": { @@ -4741,6 +4742,16 @@ "url": "https://opencollective.com/sinon" } }, + "node_modules/sinon-express-mock": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/sinon-express-mock/-/sinon-express-mock-2.2.1.tgz", + "integrity": "sha512-z1wqaPMwEnfn0SpigFhVYVS/ObX1tkqyRzRdccX99FgQaLkxGSo4684unr3NCqWeYZ1zchxPw7oFIDfzg1cAjg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "sinon": "*" + } + }, "node_modules/sinon/node_modules/@sinonjs/fake-timers": { "version": "11.2.2", "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", diff --git a/package.json b/package.json index 34abc60ef..8da4597a6 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "mocha": "^10.2.0", "nodemon": "^2.0.20", "nyc": "^15.1.0", - "sinon": "^17.0.1" + "sinon": "^17.0.1", + "sinon-express-mock": "^2.2.1" } } diff --git a/server/Database.js b/server/Database.js index 213c2c61b..4479fcd39 100644 --- a/server/Database.js +++ b/server/Database.js @@ -157,11 +157,21 @@ class Database { return this.models.mediaItemShare } + /** @type {typeof import('./models/UserBookRating')} */ + get userBookRatingModel() { + return this.models.userBookRating + } + /** @type {typeof import('./models/Device')} */ get deviceModel() { return this.models.device } + /** @type {typeof import('./models/UserBookExplicitRating')} */ + get userBookExplicitRatingModel() { + return this.models.userBookExplicitRating + } + /** * Check if db file exists * @returns {boolean} @@ -345,6 +355,8 @@ class Database { require('./models/Setting').init(this.sequelize) require('./models/CustomMetadataProvider').init(this.sequelize) require('./models/MediaItemShare').init(this.sequelize) + require('./models/UserBookRating').init(this.sequelize) + require('./models/UserBookExplicitRating').init(this.sequelize) return this.sequelize.sync({ force, alter: false }) } diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 5247dbb06..86f7181dd 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -1,4 +1,5 @@ const { Request, Response, NextFunction } = require('express') +const { Op } = require('sequelize') const Path = require('path') const fs = require('../libs/fsExtra') const uaParserJs = require('../libs/uaParser') @@ -39,6 +40,70 @@ const ShareManager = require('../managers/ShareManager') class LibraryItemController { constructor() {} + async _getExpandedItemWithRatings(libraryItem, user) { + const item = libraryItem.toOldJSONExpanded() + + if (libraryItem.isBook) { + if (global.ServerSettings.enableRating) { + // Include users personal rating + const userBookRating = await Database.userBookRatingModel.findOne({ + where: { userId: user.id, bookId: libraryItem.media.id } + }) + if (userBookRating) { + item.media.myRating = userBookRating.rating + } + + if (global.ServerSettings.enableCommunityRating) { + // Include all users ratings for community rating + const allBookRatings = await Database.userBookRatingModel.findAll({ + where: { + bookId: libraryItem.media.id, + userId: { [Op.ne]: user.id } + } + }) + + if (allBookRatings.length > 0) { + const totalRating = allBookRatings.reduce((acc, cur) => acc + cur.rating, 0) + item.media.communityRating = { + average: totalRating / allBookRatings.length, + count: allBookRatings.length + } + } + } + } + + if (global.ServerSettings.enableExplicitRating) { + // Include users personal explicit rating + const userBookExplicitRating = await Database.userBookExplicitRatingModel.findOne({ + where: { userId: user.id, bookId: libraryItem.media.id } + }) + if (userBookExplicitRating) { + item.media.myExplicitRating = userBookExplicitRating.rating + } + + if (global.ServerSettings.enableCommunityRating) { + // Include all users explicit ratings for community explicit rating + const allBookExplicitRatings = await Database.userBookExplicitRatingModel.findAll({ + where: { + bookId: libraryItem.media.id, + userId: { [Op.ne]: user.id } + } + }) + + if (allBookExplicitRatings.length > 0) { + const totalExplicitRating = allBookExplicitRatings.reduce((acc, cur) => acc + cur.rating, 0) + item.media.communityExplicitRating = { + average: totalExplicitRating / allBookExplicitRatings.length, + count: allBookExplicitRatings.length + } + } + } + } + } + + return item + } + /** * GET: /api/items/:id * Optional query params: @@ -51,7 +116,7 @@ class LibraryItemController { async findOne(req, res) { const includeEntities = (req.query.include || '').split(',') if (req.query.expanded == 1) { - const item = req.libraryItem.toOldJSONExpanded() + const item = await LibraryItemController.prototype._getExpandedItemWithRatings(req.libraryItem, req.user) // Include users media progress if (includeEntities.includes('progress')) { @@ -255,9 +320,13 @@ class LibraryItemController { Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`) SocketAuthority.libraryItemEmitter('item_updated', req.libraryItem) } + + const updatedLibraryItem = await Database.libraryItemModel.getExpandedById(req.libraryItem.id) + const itemWithRatings = await LibraryItemController.prototype._getExpandedItemWithRatings(updatedLibraryItem, req.user) + res.json({ updated: hasUpdates, - libraryItem: req.libraryItem.toOldJSON() + libraryItem: itemWithRatings }) } @@ -1181,8 +1250,8 @@ class LibraryItemController { } } - if (req.path.includes('/play')) { - // allow POST requests using /play and /play/:episodeId + if (req.path.includes('/play') || req.path.includes('/rate') || req.path.includes('/rate-explicit')) { + // allow POST requests using /play and /play/:episodeId OR /rate and /rate-explicit } else if (req.method == 'DELETE' && !req.user.canDelete) { Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to delete without permission`) return res.sendStatus(403) @@ -1193,5 +1262,70 @@ class LibraryItemController { next() } + + /** + * POST: /api/items/:id/rate + * + * @param {LibraryItemControllerRequest} req + * @param {Response} res + */ + async rate(req, res) { + if (!global.ServerSettings.enableRating) { + return res.status(403).json({ error: 'Rating is disabled' }) + } + try { + const { rating } = req.body + if (rating === null || typeof rating !== 'number' || rating < 0 || rating > 5) { + return res.status(400).json({ error: 'Invalid rating' }) + } + + const bookId = req.libraryItem.media.id + const userId = req.user.id + + await Database.userBookRatingModel.upsert({ userId, bookId, rating }) + + const updatedLibraryItem = await Database.libraryItemModel.getExpandedById(req.libraryItem.id) + const itemWithRatings = await LibraryItemController.prototype._getExpandedItemWithRatings(updatedLibraryItem, req.user) + + res.status(200).json({ success: true, libraryItem: itemWithRatings }) + } catch (err) { + Logger.error(err) + res.status(500).json({ error: 'An error occurred while saving the rating' }) + } + } + + /** + * POST: /api/items/:id/rate-explicit + * + * @param {LibraryItemControllerRequest} req + * @param {Response} res + */ + async rateExplicit(req, res) { + if (!global.ServerSettings.enableExplicitRating) { + return res.status(403).json({ error: 'Explicit rating is disabled' }) + } + try { + const { rating } = req.body + if (rating === null || typeof rating !== 'number' || rating < 0 || rating > 5) { + return res.status(400).json({ error: 'Invalid rating' }) + } + + const bookId = req.libraryItem.media.id + const userId = req.user.id + + await Database.userBookExplicitRatingModel.upsert({ userId, bookId, rating }) + + const updatedLibraryItem = await Database.libraryItemModel.getExpandedById(req.libraryItem.id) + const itemWithRatings = await LibraryItemController.prototype._getExpandedItemWithRatings(updatedLibraryItem, req.user) + + res.status(200).json({ success: true, libraryItem: itemWithRatings }) + } catch (err) { + Logger.error(err) + res.status(500).json({ error: 'An error occurred while saving the explicit rating' }) + } + } } -module.exports = new LibraryItemController() + +const controller = new LibraryItemController() + +module.exports = controller diff --git a/server/migrations/v2.25.2-add-book-ratings.js b/server/migrations/v2.25.2-add-book-ratings.js new file mode 100644 index 000000000..d40d81574 --- /dev/null +++ b/server/migrations/v2.25.2-add-book-ratings.js @@ -0,0 +1,174 @@ +const { DataTypes } = require('sequelize') + +const migrationName = 'v2.25.2-add-book-ratings' +const loggerPrefix = `[${migrationName} migration]` + +module.exports = { + up: async ({ context: { queryInterface, logger } }) => { + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + const transaction = await queryInterface.sequelize.transaction() + try { + const booksTable = await queryInterface.describeTable('books') + logger.info(`${loggerPrefix} adding columns to books table`) + if (!booksTable.providerRating) { + await queryInterface.addColumn( + 'books', + 'providerRating', + { + type: DataTypes.FLOAT + }, + { transaction } + ) + } + if (!booksTable.provider) { + await queryInterface.addColumn( + 'books', + 'provider', + { + type: DataTypes.STRING + }, + { transaction } + ) + } + if (!booksTable.providerId) { + await queryInterface.addColumn( + 'books', + 'providerId', + { + type: DataTypes.STRING + }, + { transaction } + ) + } + logger.info(`${loggerPrefix} added columns to books table`) + + const tables = await queryInterface.showAllTables() + + if (!tables.includes('userBookRatings')) { + logger.info(`${loggerPrefix} creating userBookRatings table`) + await queryInterface.createTable( + 'userBookRatings', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.STRING, + allowNull: false, + references: { model: 'users', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + bookId: { + type: DataTypes.STRING, + allowNull: false, + references: { model: 'books', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + rating: { + type: DataTypes.FLOAT, + allowNull: false + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }, + { transaction } + ) + await queryInterface.addConstraint('userBookRatings', { + fields: ['userId', 'bookId'], + type: 'unique', + name: 'user_book_ratings_unique_constraint', + transaction + }) + logger.info(`${loggerPrefix} created userBookRatings table`) + } + + if (!tables.includes('userBookExplicitRatings')) { + logger.info(`${loggerPrefix} creating userBookExplicitRatings table`) + await queryInterface.createTable( + 'userBookExplicitRatings', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.STRING, + allowNull: false, + references: { model: 'users', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + bookId: { + type: DataTypes.STRING, + allowNull: false, + references: { model: 'books', key: 'id' }, + onUpdate: 'CASCADE', + onDelete: 'CASCADE' + }, + rating: { + type: DataTypes.FLOAT, + allowNull: false + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false + } + }, + { transaction } + ) + await queryInterface.addConstraint('userBookExplicitRatings', { + fields: ['userId', 'bookId'], + type: 'unique', + name: 'user_book_explicit_ratings_unique_constraint', + transaction + }) + logger.info(`${loggerPrefix} created userBookExplicitRatings table`) + } + + await transaction.commit() + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) + } catch (err) { + await transaction.rollback() + logger.error(`${loggerPrefix} UPGRADE FAILED: ${migrationName}`, { error: err }) + throw err + } + }, + down: async ({ context: { queryInterface, logger } }) => { + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + const transaction = await queryInterface.sequelize.transaction() + try { + logger.info(`${loggerPrefix} removing columns from books table`) + await queryInterface.removeColumn('books', 'providerRating', { transaction }) + await queryInterface.removeColumn('books', 'provider', { transaction }) + await queryInterface.removeColumn('books', 'providerId', { transaction }) + logger.info(`${loggerPrefix} removed columns from books table`) + logger.info(`${loggerPrefix} dropping userBookRatings table`) + await queryInterface.dropTable('userBookRatings', { transaction }) + logger.info(`${loggerPrefix} dropped userBookRatings table`) + logger.info(`${loggerPrefix} dropping userBookExplicitRatings table`) + await queryInterface.dropTable('userBookExplicitRatings', { transaction }) + logger.info(`${loggerPrefix} dropped userBookExplicitRatings table`) + await transaction.commit() + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) + } catch (err) { + await transaction.rollback() + logger.error(`${loggerPrefix} DOWNGRADE FAILED: ${migrationName}`, { error: err }) + throw err + } + } +} diff --git a/server/models/Book.js b/server/models/Book.js index 96371f3a2..56777be02 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -130,6 +130,13 @@ class Book extends Model { this.authors /** @type {import('./Series')[]} - optional if expanded */ this.series + + /** @type {number} */ + this.providerRating + /** @type {string} */ + this.provider + /** @type {string} */ + this.providerId } /** @@ -159,6 +166,10 @@ class Book extends Model { coverPath: DataTypes.STRING, duration: DataTypes.FLOAT, + providerRating: DataTypes.FLOAT, + provider: DataTypes.STRING, + providerId: DataTypes.STRING, + narrators: DataTypes.JSON, audioFiles: DataTypes.JSON, ebookFile: DataTypes.JSON, @@ -357,7 +368,8 @@ class Book extends Model { asin: this.asin, language: this.language, explicit: !!this.explicit, - abridged: !!this.abridged + abridged: !!this.abridged, + rating: this.providerRating } } @@ -405,6 +417,10 @@ class Book extends Model { this.abridged = !!payload.metadata.abridged hasUpdates = true } + if (payload.metadata.rating !== undefined && this.providerRating !== payload.metadata.rating) { + this.providerRating = payload.metadata.rating + hasUpdates = true + } const arrayOfStringsKeys = ['narrators', 'genres'] arrayOfStringsKeys.forEach((key) => { if (Array.isArray(payload.metadata[key]) && !payload.metadata[key].some((item) => typeof item !== 'string') && JSON.stringify(this[key]) !== JSON.stringify(payload.metadata[key])) { @@ -415,6 +431,21 @@ class Book extends Model { }) } + if (payload.provider_data) { + if (this.providerRating !== payload.provider_data.rating) { + this.providerRating = payload.provider_data.rating + hasUpdates = true + } + if (this.provider !== payload.provider_data.provider) { + this.provider = payload.provider_data.provider + hasUpdates = true + } + if (this.providerId !== payload.provider_data.providerId) { + this.providerId = payload.provider_data.providerId + hasUpdates = true + } + } + if (Array.isArray(payload.tags) && !payload.tags.some((tag) => typeof tag !== 'string') && JSON.stringify(this.tags) !== JSON.stringify(payload.tags)) { this.tags = payload.tags this.changed('tags', true) @@ -569,7 +600,8 @@ class Book extends Model { asin: this.asin, language: this.language, explicit: this.explicit, - abridged: this.abridged + abridged: this.abridged, + rating: this.providerRating } } @@ -591,7 +623,8 @@ class Book extends Model { asin: this.asin, language: this.language, explicit: this.explicit, - abridged: this.abridged + abridged: this.abridged, + rating: this.providerRating } } @@ -603,6 +636,7 @@ class Book extends Model { oldMetadataJSON.narratorName = (this.narrators || []).join(', ') oldMetadataJSON.seriesName = this.seriesName oldMetadataJSON.descriptionPlain = this.description ? htmlSanitizer.stripAllTags(this.description) : null + oldMetadataJSON.rating = this.providerRating return oldMetadataJSON } @@ -680,7 +714,10 @@ class Book extends Model { ebookFile: structuredClone(this.ebookFile), duration: this.duration, size: this.size, - tracks: this.getTracklist(libraryItemId) + tracks: this.getTracklist(libraryItemId), + provider: this.provider, + providerId: this.providerId, + providerRating: this.providerRating } } } diff --git a/server/models/UserBookExplicitRating.js b/server/models/UserBookExplicitRating.js new file mode 100644 index 000000000..b369ff3c3 --- /dev/null +++ b/server/models/UserBookExplicitRating.js @@ -0,0 +1,54 @@ +const { DataTypes, Model } = require('sequelize') + +class UserBookExplicitRating extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.STRING, + allowNull: false + }, + bookId: { + type: DataTypes.STRING, + allowNull: false + }, + rating: { + type: DataTypes.FLOAT, + allowNull: false + } + }, + { + sequelize, + modelName: 'userBookExplicitRating', + tableName: 'userBookExplicitRatings', + indexes: [ + { + unique: true, + fields: ['userId', 'bookId'] + } + ] + } + ) + + const { user, book } = sequelize.models + + user.hasMany(UserBookExplicitRating, { + foreignKey: 'userId' + }) + + this.belongsTo(user, { foreignKey: 'userId' }) + + book.hasMany(this, { + foreignKey: 'bookId' + }) + + this.belongsTo(book, { foreignKey: 'bookId' }) + } +} + +module.exports = UserBookExplicitRating diff --git a/server/models/UserBookRating.js b/server/models/UserBookRating.js new file mode 100644 index 000000000..c0c929124 --- /dev/null +++ b/server/models/UserBookRating.js @@ -0,0 +1,53 @@ +const { DataTypes, Model } = require('sequelize') + +class UserBookRating extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + userId: { + type: DataTypes.STRING, + allowNull: false + }, + bookId: { + type: DataTypes.STRING, + allowNull: false + }, + rating: { + type: DataTypes.FLOAT, + allowNull: false + } + }, + { + sequelize, + modelName: 'userBookRating', + indexes: [ + { + unique: true, + fields: ['userId', 'bookId'] + } + ] + } + ) + + const { user, book } = sequelize.models + + user.hasMany(UserBookRating, { + foreignKey: 'userId' + }) + + this.belongsTo(user, { foreignKey: 'userId' }) + + book.hasMany(this, { + foreignKey: 'bookId' + }) + + this.belongsTo(book, { foreignKey: 'bookId' }) + } +} + +module.exports = UserBookRating diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index a03e17c75..a1e4f25da 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -60,6 +60,11 @@ class ServerSettings { this.version = packageJson.version this.buildNumber = packageJson.buildNumber + // Ratings + this.enableRating = true + this.enableCommunityRating = false + this.enableExplicitRating = false + // Auth settings this.authLoginCustomMessage = null this.authActiveAuthMethods = ['local'] @@ -126,6 +131,10 @@ class ServerSettings { this.version = settings.version || null this.buildNumber = settings.buildNumber || 0 // Added v2.4.5 + this.enableRating = settings.enableRating !== false + this.enableCommunityRating = !!settings.enableCommunityRating + this.enableExplicitRating = !!settings.enableExplicitRating + this.authLoginCustomMessage = settings.authLoginCustomMessage || null // Added v2.8.0 this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local'] @@ -237,6 +246,9 @@ class ServerSettings { logLevel: this.logLevel, version: this.version, buildNumber: this.buildNumber, + enableRating: this.enableRating, + enableCommunityRating: this.enableCommunityRating, + enableExplicitRating: this.enableExplicitRating, authLoginCustomMessage: this.authLoginCustomMessage, authActiveAuthMethods: this.authActiveAuthMethods, authOpenIDIssuerURL: this.authOpenIDIssuerURL, diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 6446ecc80..9f214f1e6 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -126,6 +126,8 @@ class ApiRouter { this.router.get('/items/:id/file/:fileid/download', LibraryItemController.middleware.bind(this), LibraryItemController.downloadLibraryFile.bind(this)) 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.post('/items/:id/rate', LibraryItemController.middleware.bind(this), LibraryItemController.rate.bind(this)) + this.router.post('/items/:id/rate-explicit', LibraryItemController.middleware.bind(this), LibraryItemController.rateExplicit.bind(this)) // // User Routes diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 206068cc4..0b8bc5334 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -193,7 +193,7 @@ class Scanner { */ async quickMatchBookBuildUpdatePayload(apiRouterCtx, libraryItem, matchData, options) { // Update media metadata if not set OR overrideDetails flag - const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn'] + const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn', 'rating'] const updatePayload = {} for (const key in matchData) { @@ -236,6 +236,12 @@ class Scanner { } } + if (matchData.rating && (!libraryItem.media.providerRating || options.overrideDetails)) { + updatePayload.providerRating = matchData.rating + updatePayload.provider = 'audible' + updatePayload.providerId = matchData.asin + } + // Add or set author if not set let hasAuthorUpdates = false if (matchData.author && (!libraryItem.media.authorName || options.overrideDetails)) { diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index 5a0422392..5bcc82a34 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -1,6 +1,8 @@ const { expect } = require('chai') const { Sequelize } = require('sequelize') const sinon = require('sinon') +const chai = require('chai') +const { mockReq, mockRes } = require('sinon-express-mock') const Database = require('../../../server/Database') const ApiRouter = require('../../../server/routers/ApiRouter') @@ -8,13 +10,25 @@ const LibraryItemController = require('../../../server/controllers/LibraryItemCo const ApiCacheManager = require('../../../server/managers/ApiCacheManager') const Auth = require('../../../server/Auth') const Logger = require('../../../server/Logger') +const ServerSettings = require('../../../server/objects/settings/ServerSettings') +const Book = require('../../../server/models/Book') +const User = require('../../../server/models/User') +const RssFeedManager = require('../../../server/managers/RssFeedManager') +const CacheManager = require('../../../server/managers/CacheManager') +const fs = require('../../../server/libs/fsExtra') +const SocketAuthority = require('../../../server/SocketAuthority') describe('LibraryItemController', () => { /** @type {ApiRouter} */ let apiRouter + let sandbox beforeEach(async () => { - global.ServerSettings = {} + sandbox = sinon.createSandbox() + sandbox.stub(Logger, 'info') + sandbox.stub(Logger, 'error') + global.MetadataPath = '/tmp/audiobookshelf-test' + global.ServerSettings = new ServerSettings() Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '') await Database.buildModels() @@ -23,12 +37,15 @@ describe('LibraryItemController', () => { auth: new Auth(), apiCacheManager: new ApiCacheManager() }) - - sinon.stub(Logger, 'info') + sandbox.stub(RssFeedManager, 'closeFeedForEntityId').resolves() + sandbox.stub(RssFeedManager, 'closeFeedsForEntityIds').resolves() + sandbox.stub(CacheManager, 'purgeCoverCache').resolves() + sandbox.stub(fs, 'remove').resolves() + sandbox.stub(SocketAuthority, 'emitter') }) afterEach(async () => { - sinon.restore() + sandbox.restore() // Clear all tables await Database.sequelize.sync({ force: true }) @@ -163,6 +180,7 @@ describe('LibraryItemController', () => { // Update library item 1 remove all authors and series const fakeReq = { query: {}, + user: { id: 'test-user-id' }, body: { metadata: { authors: [], @@ -199,4 +217,191 @@ describe('LibraryItemController', () => { expect(series2Exists).to.be.true }) }) + + describe('_getExpandedItemWithRatings', () => { + let user, libraryItem + + beforeEach(() => { + user = new User({ id: 'user1' }) + libraryItem = { + isBook: true, + media: { id: 'book1' }, + toOldJSONExpanded: () => ({ media: {} }) + } + sandbox.stub(Database, 'userBookRatingModel').value({ findOne: () => {}, findAll: () => [] }) + sandbox.stub(Database, 'userBookExplicitRatingModel').value({ findOne: () => {}, findAll: () => [] }) + }) + + it('should not add any rating if all rating settings are disabled', async () => { + global.ServerSettings.enableRating = false + global.ServerSettings.enableExplicitRating = false + const result = await LibraryItemController._getExpandedItemWithRatings(libraryItem, user) + expect(result.media.myRating).to.be.undefined + expect(result.media.communityRating).to.be.undefined + expect(result.media.myExplicitRating).to.be.undefined + expect(result.media.communityExplicitRating).to.be.undefined + }) + + it('should add personal rating if enabled', async () => { + global.ServerSettings.enableRating = true + sandbox.stub(Database.userBookRatingModel, 'findOne').resolves({ rating: 4 }) + const result = await LibraryItemController._getExpandedItemWithRatings(libraryItem, user) + expect(result.media.myRating).to.equal(4) + }) + + it('should add community rating if enabled', async () => { + global.ServerSettings.enableRating = true + global.ServerSettings.enableCommunityRating = true + sandbox.stub(Database.userBookRatingModel, 'findAll').resolves([{ rating: 3 }, { rating: 5 }]) + const result = await LibraryItemController._getExpandedItemWithRatings(libraryItem, user) + expect(result.media.communityRating.average).to.equal(4) + expect(result.media.communityRating.count).to.equal(2) + }) + + it('should add personal explicit rating if enabled', async () => { + global.ServerSettings.enableExplicitRating = true + sandbox.stub(Database.userBookExplicitRatingModel, 'findOne').resolves({ rating: 2 }) + const result = await LibraryItemController._getExpandedItemWithRatings(libraryItem, user) + expect(result.media.myExplicitRating).to.equal(2) + }) + + it('should add community explicit rating if enabled', async () => { + global.ServerSettings.enableExplicitRating = true + global.ServerSettings.enableCommunityRating = true + sandbox.stub(Database.userBookExplicitRatingModel, 'findAll').resolves([{ rating: 1 }, { rating: 5 }]) + const result = await LibraryItemController._getExpandedItemWithRatings(libraryItem, user) + expect(result.media.communityExplicitRating.average).to.equal(3) + expect(result.media.communityExplicitRating.count).to.equal(2) + }) + }) + + describe('updateMedia', () => { + let user, libraryItem, book, bookSaveStub + + beforeEach(async () => { + user = await Database.userModel.create({ username: 'test', password: 'password' }) + const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id }) + book = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id }) + libraryItem.media = book + libraryItem.saveMetadataFile = sinon.stub() + bookSaveStub = sandbox.stub(Book.prototype, 'save').resolves() + }) + + it('should update rating from metadata', async () => { + const req = mockReq({ + user, + libraryItem, + body: { metadata: { rating: 4.5 } } + }) + const res = mockRes() + + await LibraryItemController.updateMedia.bind(apiRouter)(req, res) + + expect(book.providerRating).to.equal(4.5) + expect(bookSaveStub.called).to.be.true + expect(res.json.calledOnce).to.be.true + }) + + it('should update rating from provider_data', async () => { + const req = mockReq({ + user, + libraryItem, + body: { + provider_data: { + rating: 4.2, + provider: 'test-provider', + providerId: 'test-id' + } + } + }) + const res = mockRes() + + await LibraryItemController.updateMedia.bind(apiRouter)(req, res) + + expect(book.providerRating).to.equal(4.2) + expect(book.provider).to.equal('test-provider') + expect(book.providerId).to.equal('test-id') + expect(bookSaveStub.called).to.be.true + expect(res.json.calledOnce).to.be.true + }) + }) + + describe('rate', () => { + let user, libraryItem, book + + beforeEach(async () => { + user = await Database.userModel.create({ username: 'test', password: 'password', id: 'user-1' }) + const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id }) + book = await Database.bookModel.create({ id: 'book-1', title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id }) + libraryItem.media = book + }) + + it('should return 403 if rating is disabled', async () => { + global.ServerSettings.enableRating = false + const req = mockReq() + const res = mockRes() + await LibraryItemController.rate.bind(apiRouter)(req, res) + expect(res.status.args[0][0]).to.equal(403) + }) + + it('should return 400 for invalid rating', async () => { + global.ServerSettings.enableRating = true + const req = mockReq({ user, libraryItem, body: { rating: 6 } }) + const res = mockRes() + await LibraryItemController.rate(req, res) + expect(res.status.args[0][0]).to.equal(400) + }) + + it('should save a valid rating and return 200', async () => { + global.ServerSettings.enableRating = true + const req = mockReq({ user, libraryItem, body: { rating: 4 } }) + const res = mockRes() + await LibraryItemController.rate(req, res) + expect(res.status.args[0][0]).to.equal(200) + const userRating = await Database.userBookRatingModel.findOne({ where: { userId: user.id, bookId: book.id } }) + expect(userRating.rating).to.equal(4) + }) + }) + + describe('rateExplicit', () => { + let user, libraryItem, book + beforeEach(async () => { + user = await Database.userModel.create({ username: 'test', password: 'password', id: 'user-1' }) + const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) + const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id }) + book = await Database.bookModel.create({ id: 'book-1', title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + libraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: book.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id }) + libraryItem.media = book + }) + + it('should return 403 if explicit rating is disabled', async () => { + global.ServerSettings.enableExplicitRating = false + const req = mockReq() + const res = mockRes() + await LibraryItemController.rateExplicit.bind(apiRouter)(req, res) + expect(res.status.args[0][0]).to.equal(403) + }) + + it('should return 400 for invalid explicit rating', async () => { + global.ServerSettings.enableExplicitRating = true + const req = mockReq({ user, libraryItem, body: { rating: -1 } }) + const res = mockRes() + await LibraryItemController.rateExplicit(req, res) + expect(res.status.args[0][0]).to.equal(400) + }) + + it('should save a valid explicit rating and return 200', async () => { + global.ServerSettings.enableExplicitRating = true + const req = mockReq({ user, libraryItem, body: { rating: 5 } }) + const res = mockRes() + await LibraryItemController.rateExplicit(req, res) + expect(res.status.args[0][0]).to.equal(200) + const userRating = await Database.userBookExplicitRatingModel.findOne({ where: { userId: user.id, bookId: book.id } }) + expect(userRating.rating).to.equal(5) + }) + }) }) diff --git a/test/server/migrations/v2.25.2-add-book-ratings.test.js b/test/server/migrations/v2.25.2-add-book-ratings.test.js new file mode 100644 index 000000000..2c49f6591 --- /dev/null +++ b/test/server/migrations/v2.25.2-add-book-ratings.test.js @@ -0,0 +1,132 @@ +const chai = require('chai') +const sinon = require('sinon') +const { expect } = chai + +const { DataTypes, Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') + +const { up, down } = require('../../../server/migrations/v2.25.2-add-book-ratings') + +describe('Migration v2.25.2-add-book-ratings', () => { + let sequelize + let queryInterface + let loggerInfoStub + + beforeEach(async () => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + loggerInfoStub = sinon.stub(Logger, 'info') + + await queryInterface.createTable('users', { + id: { type: DataTypes.STRING, primaryKey: true, allowNull: false } + }) + + await queryInterface.createTable('books', { + id: { type: DataTypes.STRING, primaryKey: true, allowNull: false } + }) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('up', () => { + it('should add columns to books table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + const table = await queryInterface.describeTable('books') + expect(table.providerRating).to.exist + expect(table.provider).to.exist + expect(table.providerId).to.exist + }) + + it('should create userBookRatings table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + const table = await queryInterface.describeTable('userBookRatings') + expect(table.id).to.exist + expect(table.userId).to.exist + expect(table.bookId).to.exist + expect(table.rating).to.exist + expect(table.createdAt).to.exist + expect(table.updatedAt).to.exist + }) + + it('should create userBookExplicitRatings table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + const table = await queryInterface.describeTable('userBookExplicitRatings') + expect(table.id).to.exist + expect(table.userId).to.exist + expect(table.bookId).to.exist + expect(table.rating).to.exist + expect(table.createdAt).to.exist + expect(table.updatedAt).to.exist + }) + + it('should add unique constraints', async () => { + await up({ context: { queryInterface, logger: Logger } }) + const constraints1 = await queryInterface.showConstraint('userBookRatings') + expect(constraints1.some((c) => c.constraintName === 'user_book_ratings_unique_constraint')).to.be.true + + const constraints2 = await queryInterface.showConstraint('userBookExplicitRatings') + expect(constraints2.some((c) => c.constraintName === 'user_book_explicit_ratings_unique_constraint')).to.be.true + }) + + it('should be idempotent', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await up({ context: { queryInterface, logger: Logger } }) + + const table = await queryInterface.describeTable('books') + expect(table.providerRating).to.exist + + const table2 = await queryInterface.describeTable('userBookRatings') + expect(table2.id).to.exist + + const table3 = await queryInterface.describeTable('userBookExplicitRatings') + expect(table3.id).to.exist + }) + }) + + describe('down', () => { + it('should remove columns from books table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const table = await queryInterface.describeTable('books') + expect(table.providerRating).to.not.exist + expect(table.provider).to.not.exist + expect(table.providerId).to.not.exist + }) + + it('should drop userBookRatings table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + let error = null + try { + await queryInterface.describeTable('userBookRatings') + } catch (e) { + error = e + } + expect(error).to.not.be.null + }) + + it('should drop userBookExplicitRatings table', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + let error = null + try { + await queryInterface.describeTable('userBookExplicitRatings') + } catch (e) { + error = e + } + expect(error).to.not.be.null + }) + + it('should be idempotent', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const table = await queryInterface.describeTable('books') + expect(table.providerRating).to.not.exist + }) + }) +})