
@@ -174,7 +177,7 @@ export default {
}
// Include episode downloads for podcasts
- var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed,share`).catch((error) => {
+ var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1&include=downloads,rssfeed,share,progress`).catch((error) => {
console.error('Failed', error)
return false
})
@@ -182,11 +185,11 @@ export default {
console.error('No item...', params.id)
return redirect('/')
}
+ store.commit('libraries/UPDATE_LIBRARY_ITEM', item)
if (store.state.libraries.currentLibraryId !== item.libraryId || !store.state.libraries.filterData) {
await store.dispatch('libraries/fetch', item.libraryId)
}
return {
- libraryItem: item,
rssFeed: item.rssFeed || null,
mediaItemShare: item.mediaItemShare || null
}
@@ -202,10 +205,14 @@ export default {
episodeDownloadsQueued: [],
showBookmarksModal: false,
isDescriptionClamped: false,
- showFullDescription: false
+ showFullDescription: false,
+ localPersonalRating: 0
}
},
computed: {
+ libraryItem() {
+ return this.$store.state.libraries.libraryItemsCache[this.$route.params.id] || {}
+ },
userToken() {
return this.$store.getters['user/getToken']
},
@@ -329,17 +336,23 @@ export default {
description() {
return this.mediaMetadata.description || ''
},
- userRating() {
+ globalRating() {
return this.mediaMetadata.rating || 0
},
providerRating() {
- console.log('Provider Rating: ', this.media.providerRating)
return this.media.providerRating || 0
},
provider() {
- console.log('Provider: ', this.media.provider)
return this.media.provider || null
},
+ personalRating: {
+ get() {
+ return this.localPersonalRating
+ },
+ set(val) {
+ this.updatePersonalRating(val)
+ }
+ },
userMediaProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
@@ -464,6 +477,51 @@ export default {
return items
}
},
+ mounted() {
+ this.localPersonalRating = this.libraryItem.personalRating || 0
+ this.$root.$on('progress-updated', this.progressUpdated)
+ this.$root.$on('libraryitem-updated', this.libraryItemUpdated)
+ this.$root.$on('rss-updated', this.rssUpdated)
+ this.checkDescriptionClamped()
+
+ this.$root.socket.on('playback_session_started', this.playbackSessionStarted)
+ this.$root.socket.on('playback_session_stopped', this.playbackSessionStopped)
+ this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
+ this.$root.socket.on('episode_download_progress', this.episodeDownloadProgress)
+ this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
+ this.$root.socket.on('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
+
+ this.episodeDownloadsQueued = this.libraryItem.episodeDownloadsQueued || []
+ this.episodesDownloading = this.libraryItem.episodesDownloading || []
+
+ this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
+ this.$root.socket.on('item_updated', this.libraryItemUpdated)
+ this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
+ this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
+ this.$root.socket.on('share_open', this.shareOpen)
+ this.$root.socket.on('share_closed', this.shareClosed)
+ this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
+ },
+ beforeDestroy() {
+ this.$root.$off('progress-updated', this.progressUpdated)
+ this.$root.$off('libraryitem-updated', this.libraryItemUpdated)
+ this.$root.$off('rss-updated', this.rssUpdated)
+ this.$root.socket.off('playback_session_started', this.playbackSessionStarted)
+ this.$root.socket.off('playback_session_stopped', this.playbackSessionStopped)
+ this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
+ this.$root.socket.off('episode_download_progress', this.episodeDownloadProgress)
+ this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
+ this.$root.socket.off('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
+ this.streamer?.off()
+
+ this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
+ this.$root.socket.off('item_updated', this.libraryItemUpdated)
+ this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
+ this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
+ this.$root.socket.off('share_open', this.shareOpen)
+ this.$root.socket.off('share_closed', this.shareClosed)
+ this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
+ },
methods: {
selectBookmark(bookmark) {
if (!bookmark) return
@@ -625,9 +683,9 @@ export default {
},
libraryItemUpdated(libraryItem) {
if (libraryItem.id === this.libraryItemId) {
- console.log('Item was updated', libraryItem)
- this.libraryItem = libraryItem
- this.$nextTick(this.checkDescriptionClamped)
+ libraryItem.personalRating = this.localPersonalRating
+ this.$store.commit('libraries/UPDATE_LIBRARY_ITEM', libraryItem)
+ this.localPersonalRating = libraryItem.personalRating || 0
}
},
clearProgressClick() {
@@ -808,36 +866,43 @@ export default {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShareModal', this.mediaItemShare)
}
+ },
+ async updatePersonalRating(rating) {
+ this.localPersonalRating = rating
+ try {
+ await this.$axios.post(`/api/items/${this.libraryItemId}/rate`, { rating })
+ } catch (err) {
+ console.error(err)
+ }
+ },
+ progressUpdated(data) {
+ if (data.libraryItemId === this.libraryItemId) {
+ this.$store.dispatch('user/updateMediaProgress', data.mediaProgress)
+ this.$nextTick(this.checkDescriptionClamped)
+ }
+ },
+ rssUpdated(data) {
+ if (data.libraryItemId === this.libraryItemId) {
+ console.log('RSS feed updated', data)
+ this.rssFeed = data
+ }
+ },
+ playbackSessionStarted(data) {
+ if (data.entityId === this.libraryItemId) {
+ this.$store.commit('setStreamLibraryItem', data)
+ }
+ },
+ playbackSessionStopped(data) {
+ if (data.entityId === this.libraryItemId) {
+ this.$store.commit('setStreamLibraryItem', null)
+ }
+ },
+ episodeDownloadProgress(data) {
+ if (data.libraryItemId === this.libraryItemId) {
+ console.log('Episode download progress', data)
+ // Handle episode download progress update
+ }
}
- },
- mounted() {
- this.checkDescriptionClamped()
-
- this.episodeDownloadsQueued = this.libraryItem.episodeDownloadsQueued || []
- this.episodesDownloading = this.libraryItem.episodesDownloading || []
-
- this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
- this.$root.socket.on('item_updated', this.libraryItemUpdated)
- this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
- this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
- this.$root.socket.on('share_open', this.shareOpen)
- this.$root.socket.on('share_closed', this.shareClosed)
- this.$root.socket.on('episode_download_queued', this.episodeDownloadQueued)
- this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
- this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
- this.$root.socket.on('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
- },
- beforeDestroy() {
- this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
- this.$root.socket.off('item_updated', this.libraryItemUpdated)
- this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
- this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
- this.$root.socket.off('share_open', this.shareOpen)
- this.$root.socket.off('share_closed', this.shareClosed)
- this.$root.socket.off('episode_download_queued', this.episodeDownloadQueued)
- this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
- this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
- this.$root.socket.off('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
}
}
diff --git a/client/store/libraries.js b/client/store/libraries.js
index 8964d9f19..5d4b7242f 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,9 @@ export const actions = {
}
export const mutations = {
+ UPDATE_LIBRARY_ITEM(state, libraryItem) {
+ Vue.set(state.libraryItemsCache, libraryItem.id, libraryItem)
+ },
setFolders(state, folders) {
state.folders = folders
},
diff --git a/server/Database.js b/server/Database.js
index a260e89f2..2fddc23c1 100644
--- a/server/Database.js
+++ b/server/Database.js
@@ -333,6 +333,7 @@ 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)
return this.sequelize.sync({ force, alter: false })
}
diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js
index 5247dbb06..7d43cfc21 100644
--- a/server/controllers/LibraryItemController.js
+++ b/server/controllers/LibraryItemController.js
@@ -53,6 +53,14 @@ class LibraryItemController {
if (req.query.expanded == 1) {
const item = req.libraryItem.toOldJSONExpanded()
+ // Include users personal rating
+ const userBookRating = await Database.models.userBookRating.findOne({
+ where: { userId: req.user.id, bookId: req.libraryItem.media.id }
+ })
+ if (userBookRating) {
+ item.personalRating = userBookRating.rating
+ }
+
// Include users media progress
if (includeEntities.includes('progress')) {
const episodeId = req.query.episode || null
@@ -1181,8 +1189,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')) {
+ // allow POST requests using /play and /play/:episodeId OR /rate
} 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 +1201,31 @@ class LibraryItemController {
next()
}
+
+ /**
+ * POST: /api/items/:id/rate
+ *
+ * @param {LibraryItemControllerRequest} req
+ * @param {Response} res
+ */
+ async rate(req, res) {
+ 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.models.userBookRating.upsert({ userId, bookId, rating })
+
+ res.status(200).json({ success: true })
+ } catch (err) {
+ Logger.error(err)
+ res.status(500).json({ error: 'An error occurred while saving the rating' })
+ }
+ }
}
+
module.exports = new LibraryItemController()
diff --git a/server/migrations/v2.25.3-add-user-book-ratings.js b/server/migrations/v2.25.3-add-user-book-ratings.js
new file mode 100644
index 000000000..9b63d4b21
--- /dev/null
+++ b/server/migrations/v2.25.3-add-user-book-ratings.js
@@ -0,0 +1,59 @@
+const { DataTypes } = require('sequelize')
+
+module.exports = {
+ up: async ({ context: queryInterface }) => {
+ const transaction = await queryInterface.sequelize.transaction()
+ try {
+ 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
+ })
+ await transaction.commit()
+ } catch (err) {
+ await transaction.rollback()
+ throw err
+ }
+ },
+ down: async ({ context: queryInterface }) => {
+ await queryInterface.dropTable('userBookRatings')
+ }
+}
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/routers/ApiRouter.js b/server/routers/ApiRouter.js
index ecb1555f1..440f8aa67 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -125,6 +125,7 @@ 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))
//
// User Routes