Added User personal audiobook rating

This commit is contained in:
Peter BALIVET 2025-06-28 08:34:04 +02:00
parent 7c3504fe2b
commit 3bfd9f419c
7 changed files with 264 additions and 46 deletions

View file

@ -36,9 +36,9 @@
<p v-if="bookSubtitle" class="text-gray-200 text-xl md:text-2xl">{{ bookSubtitle }}</p> <p v-if="bookSubtitle" class="text-gray-200 text-xl md:text-2xl">{{ bookSubtitle }}</p>
<template v-for="(_series, index) in seriesList"> <template v-for="(_series, index) in seriesList" :key="_series.id">
<nuxt-link :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7">{{ _series.text }}</nuxt-link <nuxt-link :to="`/library/${libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300 text-lg leading-7">{{ _series.text }}</nuxt-link
><span :key="index" v-if="index < seriesList.length - 1">, </span> ><span v-if="index < seriesList.length - 1">, </span>
</template> </template>
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">{{ $getString('LabelByAuthor', [podcastAuthor]) }}</p> <p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">{{ $getString('LabelByAuthor', [podcastAuthor]) }}</p>
@ -48,8 +48,11 @@
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p> <p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
<div class="flex items-center space-x-4 mt-2"> <div class="flex items-center space-x-4 mt-2">
<div v-if="userRating > 0" class="flex items-center"> <div class="flex items-center">
<ui-rating-input :value="userRating" :label="$strings.LabelYourRating" :read-only="true" /> <ui-rating-input v-model="personalRating" :label="$strings.LabelYourRating" />
</div>
<div v-if="globalRating > 0" class="flex items-center">
<ui-rating-input :value="globalRating" label="Community Rating" :read-only="true" />
</div> </div>
<div v-if="provider === 'audible' && providerRating != null" class="flex items-center bg-zinc-800 rounded-lg p-1.5 space-x-1.5 opacity-80"> <div v-if="provider === 'audible' && providerRating != null" class="flex items-center bg-zinc-800 rounded-lg p-1.5 space-x-1.5 opacity-80">
<img src="~/assets/logos/audible.svg" alt="Audible Logo" class="w-12 h-auto" /> <img src="~/assets/logos/audible.svg" alt="Audible Logo" class="w-12 h-auto" />
@ -174,7 +177,7 @@ export default {
} }
// Include episode downloads for podcasts // 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) console.error('Failed', error)
return false return false
}) })
@ -182,11 +185,11 @@ export default {
console.error('No item...', params.id) console.error('No item...', params.id)
return redirect('/') return redirect('/')
} }
store.commit('libraries/UPDATE_LIBRARY_ITEM', item)
if (store.state.libraries.currentLibraryId !== item.libraryId || !store.state.libraries.filterData) { if (store.state.libraries.currentLibraryId !== item.libraryId || !store.state.libraries.filterData) {
await store.dispatch('libraries/fetch', item.libraryId) await store.dispatch('libraries/fetch', item.libraryId)
} }
return { return {
libraryItem: item,
rssFeed: item.rssFeed || null, rssFeed: item.rssFeed || null,
mediaItemShare: item.mediaItemShare || null mediaItemShare: item.mediaItemShare || null
} }
@ -202,10 +205,14 @@ export default {
episodeDownloadsQueued: [], episodeDownloadsQueued: [],
showBookmarksModal: false, showBookmarksModal: false,
isDescriptionClamped: false, isDescriptionClamped: false,
showFullDescription: false showFullDescription: false,
localPersonalRating: 0
} }
}, },
computed: { computed: {
libraryItem() {
return this.$store.state.libraries.libraryItemsCache[this.$route.params.id] || {}
},
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
}, },
@ -329,17 +336,23 @@ export default {
description() { description() {
return this.mediaMetadata.description || '' return this.mediaMetadata.description || ''
}, },
userRating() { globalRating() {
return this.mediaMetadata.rating || 0 return this.mediaMetadata.rating || 0
}, },
providerRating() { providerRating() {
console.log('Provider Rating: ', this.media.providerRating)
return this.media.providerRating || 0 return this.media.providerRating || 0
}, },
provider() { provider() {
console.log('Provider: ', this.media.provider)
return this.media.provider || null return this.media.provider || null
}, },
personalRating: {
get() {
return this.localPersonalRating
},
set(val) {
this.updatePersonalRating(val)
}
},
userMediaProgress() { userMediaProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
}, },
@ -464,6 +477,51 @@ export default {
return items 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: { methods: {
selectBookmark(bookmark) { selectBookmark(bookmark) {
if (!bookmark) return if (!bookmark) return
@ -625,9 +683,9 @@ export default {
}, },
libraryItemUpdated(libraryItem) { libraryItemUpdated(libraryItem) {
if (libraryItem.id === this.libraryItemId) { if (libraryItem.id === this.libraryItemId) {
console.log('Item was updated', libraryItem) libraryItem.personalRating = this.localPersonalRating
this.libraryItem = libraryItem this.$store.commit('libraries/UPDATE_LIBRARY_ITEM', libraryItem)
this.$nextTick(this.checkDescriptionClamped) this.localPersonalRating = libraryItem.personalRating || 0
} }
}, },
clearProgressClick() { clearProgressClick() {
@ -808,36 +866,43 @@ export default {
this.$store.commit('setSelectedLibraryItem', this.libraryItem) this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShareModal', this.mediaItemShare) 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)
} }
} }
</script> </script>

View file

@ -1,3 +1,4 @@
import Vue from 'vue'
const { Constants } = require('../plugins/constants') const { Constants } = require('../plugins/constants')
export const state = () => ({ export const state = () => ({
@ -12,7 +13,8 @@ export const state = () => ({
numUserPlaylists: 0, numUserPlaylists: 0,
collections: [], collections: [],
userPlaylists: [], userPlaylists: [],
ereaderDevices: [] ereaderDevices: [],
libraryItemsCache: {}
}) })
export const getters = { export const getters = {
@ -170,6 +172,9 @@ export const actions = {
} }
export const mutations = { export const mutations = {
UPDATE_LIBRARY_ITEM(state, libraryItem) {
Vue.set(state.libraryItemsCache, libraryItem.id, libraryItem)
},
setFolders(state, folders) { setFolders(state, folders) {
state.folders = folders state.folders = folders
}, },

View file

@ -333,6 +333,7 @@ class Database {
require('./models/Setting').init(this.sequelize) require('./models/Setting').init(this.sequelize)
require('./models/CustomMetadataProvider').init(this.sequelize) require('./models/CustomMetadataProvider').init(this.sequelize)
require('./models/MediaItemShare').init(this.sequelize) require('./models/MediaItemShare').init(this.sequelize)
require('./models/UserBookRating').init(this.sequelize)
return this.sequelize.sync({ force, alter: false }) return this.sequelize.sync({ force, alter: false })
} }

View file

@ -53,6 +53,14 @@ class LibraryItemController {
if (req.query.expanded == 1) { if (req.query.expanded == 1) {
const item = req.libraryItem.toOldJSONExpanded() 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 // Include users media progress
if (includeEntities.includes('progress')) { if (includeEntities.includes('progress')) {
const episodeId = req.query.episode || null const episodeId = req.query.episode || null
@ -1181,8 +1189,8 @@ class LibraryItemController {
} }
} }
if (req.path.includes('/play')) { if (req.path.includes('/play') || req.path.includes('/rate')) {
// allow POST requests using /play and /play/:episodeId // allow POST requests using /play and /play/:episodeId OR /rate
} else if (req.method == 'DELETE' && !req.user.canDelete) { } else if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to delete without permission`) Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to delete without permission`)
return res.sendStatus(403) return res.sendStatus(403)
@ -1193,5 +1201,31 @@ class LibraryItemController {
next() 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() module.exports = new LibraryItemController()

View file

@ -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')
}
}

View file

@ -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

View file

@ -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/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.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.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 // User Routes