mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-08-28 13:46:20 +02:00
Added Explicit user book rating + Community rating
This commit is contained in:
parent
3bfd9f419c
commit
dba575761e
@ -6,8 +6,8 @@
|
||||
</div>
|
||||
</template>
|
||||
<div role="tablist" class="absolute -top-10 left-0 z-10 w-full flex">
|
||||
<template v-for="tab in availableTabs">
|
||||
<button :key="tab.id" role="tab" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</button>
|
||||
<template v-for="tab in availableTabs" :key="tab.id">
|
||||
<button role="tab" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@ -245,8 +245,22 @@ export default {
|
||||
this.processing = false
|
||||
}
|
||||
},
|
||||
libraryItemUpdated(expandedLibraryItem) {
|
||||
this.libraryItem = expandedLibraryItem
|
||||
libraryItemUpdated(updatedLibraryItem) {
|
||||
if (this.libraryItem && this.libraryItem.id === updatedLibraryItem.id) {
|
||||
// The updated item from the server doesn't contain rating data, so we preserve it from the client-side model.
|
||||
if (this.libraryItem.media) {
|
||||
updatedLibraryItem.media = {
|
||||
...(updatedLibraryItem.media || {}),
|
||||
myRating: this.libraryItem.media.myRating,
|
||||
communityRating: this.libraryItem.media.communityRating,
|
||||
myExplicitRating: this.libraryItem.media.myExplicitRating,
|
||||
communityExplicitRating: this.libraryItem.media.communityExplicitRating,
|
||||
}
|
||||
}
|
||||
this.libraryItem = updatedLibraryItem
|
||||
} else {
|
||||
this.libraryItem = updatedLibraryItem
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.fetchFull()
|
||||
|
@ -169,6 +169,7 @@ export default {
|
||||
if (updateResult) {
|
||||
if (updateResult.updated) {
|
||||
this.$toast.success(this.$strings.ToastItemDetailsUpdateSuccess)
|
||||
this.$eventBus.$emit(`${this.libraryItemId}_updated`, updateResult.libraryItem)
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
|
32
client/components/ui/FlameIcon.vue
Normal file
32
client/components/ui/FlameIcon.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<svg :viewBox="viewBox" :fill="empty ? 'transparent' : color" :stroke="empty ? '#d1d5db' : 'none'" stroke-width="1.5">
|
||||
<path :d="path1" />
|
||||
<path :d="path2" :fill="empty ? 'transparent' : color2" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
empty: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '#EC6F59'
|
||||
},
|
||||
color2: {
|
||||
type: String,
|
||||
default: '#FAD15C'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
path1: 'M18.61,54.89C15.7,28.8,30.94,10.45,59.52,0C42.02,22.71,74.44,47.31,76.23,70.89 c4.19-7.15,6.57-16.69,7.04-29.45c21.43,33.62,3.66,88.57-43.5,80.67c-4.33-0.72-8.5-2.09-12.3-4.13C10.27,108.8,0,88.79,0,69.68 C0,57.5,5.21,46.63,11.95,37.99C12.85,46.45,14.77,52.76,18.61,54.89L18.61,54.89z',
|
||||
path2: 'M33.87,92.58c-4.86-12.55-4.19-32.82,9.42-39.93c0.1,23.3,23.05,26.27,18.8,51.14 c3.92-4.44,5.9-11.54,6.25-17.15c6.22,14.24,1.34,25.63-7.53,31.43c-26.97,17.64-50.19-18.12-34.75-37.72 C26.53,84.73,31.89,91.49,33.87,92.58L33.87,92.58z',
|
||||
viewBox: '0 0 92.27 122.88'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,32 +1,34 @@
|
||||
<template>
|
||||
<div class="rating-input-container">
|
||||
<label v-if="label" class="px-1 text-sm font-semibold">{{ label }}</label>
|
||||
<div class="rating-input flex items-center" :aria-label="label" :class="{ 'read-only': readOnly }">
|
||||
<div
|
||||
class="flex items-center"
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
class="star-container relative"
|
||||
:data-star="star"
|
||||
@mouseleave="handleMouseleave"
|
||||
@mousemove="handleMousemove"
|
||||
@click="handleClick()"
|
||||
>
|
||||
<div
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
class="star-container relative"
|
||||
:data-star="star"
|
||||
@mousemove="handleMousemove"
|
||||
@click="handleClick()"
|
||||
>
|
||||
<template v-if="icon === 'star'">
|
||||
<svg class="star star-empty" viewBox="0 0 24 24">
|
||||
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||
</svg>
|
||||
<svg class="star star-filled absolute top-0 left-0" :style="{ clipPath: getClipPath(star) }" viewBox="0 0 24 24">
|
||||
<svg class="star star-filled absolute top-0 left-0" :style="{ clipPath: getClipPath(star), fill: color, stroke: color }" viewBox="0 0 24 24">
|
||||
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="ml-2 text-white/70">{{ displayValue }}/5</span>
|
||||
</template>
|
||||
<template v-if="icon === 'flame'">
|
||||
<ui-flame-icon class="star" :empty="true" />
|
||||
<ui-flame-icon class="star star-filled absolute top-0 left-0" :style="{ clipPath: getClipPath(star) }" />
|
||||
</template>
|
||||
</div>
|
||||
<span class="ml-2 text-white/70">{{ displayValue }}/5</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'RatingInput',
|
||||
props: {
|
||||
value: {
|
||||
type: Number,
|
||||
@ -34,11 +36,19 @@ export default {
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: 'Rating'
|
||||
},
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'star'
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '#f59e0b'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@ -97,11 +107,8 @@ export default {
|
||||
.star-empty {
|
||||
fill: transparent;
|
||||
stroke: #d1d5db;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.star-filled {
|
||||
fill: #f59e0b;
|
||||
stroke: #f59e0b;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
</style>
|
@ -42,6 +42,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsRatings }}</h2>
|
||||
</div>
|
||||
|
||||
<div role="article" :aria-label="$strings.LabelSettingsEnableRatingHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsEnableRating" v-model="newServerSettings.enableRating" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableRating', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsEnableRatingHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-enable-rating">{{ $strings.LabelSettingsEnableRating }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div role="article" :aria-label="$strings.LabelSettingsEnableCommunityRatingHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsEnableCommunityRating" v-model="newServerSettings.enableCommunityRating" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableCommunityRating', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsEnableCommunityRatingHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-enable-community-rating">{{ $strings.LabelSettingsEnableCommunityRating }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div role="article" :aria-label="$strings.LabelSettingsEnableExplicitRatingHelp" class="flex items-center py-2">
|
||||
<ui-toggle-switch :label="$strings.LabelSettingsEnableExplicitRating" v-model="newServerSettings.enableExplicitRating" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableExplicitRating', val)" />
|
||||
<ui-tooltip aria-hidden="true" :text="$strings.LabelSettingsEnableExplicitRatingHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-enable-explicit-rating">{{ $strings.LabelSettingsEnableExplicitRating }}</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2>
|
||||
</div>
|
||||
|
@ -47,19 +47,34 @@
|
||||
</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">
|
||||
<!-- RATING SECTION -->
|
||||
<div v-if="serverSettings.enableRating" class="flex items-center space-x-4 mt-2">
|
||||
<div class="flex items-center">
|
||||
<ui-rating-input v-model="personalRating" :label="$strings.LabelYourRating" />
|
||||
<ui-rating-input :value="myRating" :label="$strings.LabelYourRating" @input="updateRating" />
|
||||
</div>
|
||||
<div v-if="globalRating > 0" class="flex items-center">
|
||||
<ui-rating-input :value="globalRating" label="Community Rating" :read-only="true" />
|
||||
<div v-if="serverSettings.enableCommunityRating && communityRating.count > 0" class="flex items-center bg-zinc-800 rounded-lg py-1.5 px-2 space-x-1.5 opacity-80">
|
||||
<span class="material-symbols text-lg -ml-0.5">groups</span>
|
||||
<span class="font-semibold text-white">{{ communityRating.average.toFixed(1) }}/5</span>
|
||||
<span class="text-xs text-white/50">({{ communityRating.count }})</span>
|
||||
</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">
|
||||
<img src="~/assets/logos/audible.svg" alt="Audible Logo" class="w-12 h-auto" />
|
||||
<div v-if="providerRating > 0 && provider === 'audible'" class="flex items-center bg-zinc-800 rounded-lg py-1.5 px-2 space-x-1.5 opacity-80">
|
||||
<img src="/audible.svg" alt="Audible Logo" class="h-5 w-auto" />
|
||||
<span class="font-semibold text-white">{{ providerRating.toFixed(1) }}/5</span>
|
||||
</div>
|
||||
<div v-else-if="providerRating > 0" class="flex items-center">
|
||||
<ui-rating-input :value="providerRating" :read-only="true" />
|
||||
<ui-rating-input :value="providerRating" :label="provider" :read-only="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EXPLICIT RATING SECTION -->
|
||||
<div v-if="serverSettings.enableExplicitRating && isExplicit" class="flex items-center space-x-4 mt-3">
|
||||
<div class="flex items-center">
|
||||
<ui-rating-input :value="myExplicitRating" :label="$strings.LabelExplicitRating" icon="flame" @input="updateExplicitRating" />
|
||||
</div>
|
||||
<div v-if="serverSettings.enableCommunityRating && communityExplicitRating.count > 0" class="flex items-center bg-zinc-800 rounded-lg py-1.5 px-2 space-x-1.5 opacity-80">
|
||||
<ui-flame-icon class="h-5 w-5 -ml-0.5" />
|
||||
<span class="font-semibold text-white">{{ communityExplicitRating.average.toFixed(1) }}/5</span>
|
||||
<span class="text-xs text-white/50">({{ communityExplicitRating.count }})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -206,7 +221,6 @@ export default {
|
||||
showBookmarksModal: false,
|
||||
isDescriptionClamped: false,
|
||||
showFullDescription: false,
|
||||
localPersonalRating: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -216,6 +230,9 @@ export default {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings
|
||||
},
|
||||
downloadUrl() {
|
||||
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}`
|
||||
},
|
||||
@ -336,8 +353,17 @@ export default {
|
||||
description() {
|
||||
return this.mediaMetadata.description || ''
|
||||
},
|
||||
globalRating() {
|
||||
return this.mediaMetadata.rating || 0
|
||||
myRating() {
|
||||
return this.media.myRating
|
||||
},
|
||||
communityRating() {
|
||||
return this.media.communityRating || { average: 0, count: 0 }
|
||||
},
|
||||
myExplicitRating() {
|
||||
return this.media.myExplicitRating
|
||||
},
|
||||
communityExplicitRating() {
|
||||
return this.media.communityExplicitRating || { average: 0, count: 0 }
|
||||
},
|
||||
providerRating() {
|
||||
return this.media.providerRating || 0
|
||||
@ -345,14 +371,6 @@ export default {
|
||||
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)
|
||||
},
|
||||
@ -478,7 +496,6 @@ export default {
|
||||
}
|
||||
},
|
||||
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)
|
||||
@ -683,9 +700,7 @@ export default {
|
||||
},
|
||||
libraryItemUpdated(libraryItem) {
|
||||
if (libraryItem.id === this.libraryItemId) {
|
||||
libraryItem.personalRating = this.localPersonalRating
|
||||
this.$store.commit('libraries/UPDATE_LIBRARY_ITEM', libraryItem)
|
||||
this.localPersonalRating = libraryItem.personalRating || 0
|
||||
}
|
||||
},
|
||||
clearProgressClick() {
|
||||
@ -867,12 +882,26 @@ export default {
|
||||
this.$store.commit('globals/setShareModal', this.mediaItemShare)
|
||||
}
|
||||
},
|
||||
async updatePersonalRating(rating) {
|
||||
this.localPersonalRating = rating
|
||||
async updateRating(rating) {
|
||||
try {
|
||||
await this.$axios.post(`/api/items/${this.libraryItemId}/rate`, { rating })
|
||||
const res = await this.$axios.$post(`/api/items/${this.libraryItemId}/rate`, { rating })
|
||||
if (res.libraryItem) {
|
||||
this.$store.commit('libraries/UPDATE_LIBRARY_ITEM', res.libraryItem)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
}
|
||||
},
|
||||
async updateExplicitRating(rating) {
|
||||
try {
|
||||
const res = await this.$axios.$post(`/api/items/${this.libraryItemId}/rate-explicit`, { rating })
|
||||
if (res.libraryItem) {
|
||||
this.$store.commit('libraries/UPDATE_LIBRARY_ITEM', res.libraryItem)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||
}
|
||||
},
|
||||
progressUpdated(data) {
|
||||
|
@ -1,2 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg" style="enable-background:new 0 0 192 192" xml:space="preserve"><path fill="#FF9900" d="m129.8 96.1.2-.1c3.5-2.2 4.1-7.1 1.2-10.1-5.8-6.1-16.3-14.5-31.7-16.2-25.1-2.8-42 14.8-45.8 19.2-.4.5-.5 1.3 0 1.8.5.7 1.5.8 2.1.2 4.2-3.6 14.4-11.1 29-11.7 18.1-.7 30.9 9.8 36.7 15.8 2.2 2.3 5.6 2.8 8.3 1.1z"/><path fill="#FF9900" d="M148.8 82.8c3.7-2.2 4.4-7.2 1.5-10.4-8.5-9.5-27.3-26-55.6-26.4-34.4-.3-55.2 23.4-59.7 29-.4.6-.5 1.3-.1 1.9.6.8 1.8.9 2.5.2C42.9 71.5 62.2 54.3 91 56c25.7 1.6 42.1 17.2 49 25.3 2.2 2.7 5.9 3.3 8.8 1.5z"/><path d="m22 88.8 65.2 54.3c4.4 3.7 10.8 3.8 15.3.2L170 90.7" style="fill:none;stroke:#FF9900;stroke-width:12;stroke-linecap:round;stroke-miterlimit:10"/><path fill="#FF9900" d="M109.6 106.9c3.6-2.2 4-7.3.7-10-2.9-2.3-6.8-4.7-11.9-5.7-13.4-2.6-23.6 6.4-26.4 9.2-.5.5-.6 1.2-.3 1.7.4.8 1.4 1 2.1.6 2.4-1.5 6.4-3.4 11.5-3.5 7.8-.2 13.7 3.9 17 7 2 1.8 5 2.1 7.3.7z"/></svg>
|
||||
<svg width="800px" height="800px" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg" style="enable-background:new 0 0 192 192" xml:space="preserve"><path fill="#FF9900" d="m129.8 96.1.2-.1c3.5-2.2 4.1-7.1 1.2-10.1-5.8-6.1-16.3-14.5-31.7-16.2-25.1-2.8-42 14.8-45.8 19.2-.4.5-.5 1.3 0 1.8.5.7 1.5.8 2.1.2 4.2-3.6 14.4-11.1 29-11.7 18.1-.7 30.9 9.8 36.7 15.8 2.2 2.3 5.6 2.8 8.3 1.1z"/><path fill="#FF9900" d="M148.8 82.8c3.7-2.2 4.4-7.2 1.5-10.4-8.5-9.5-27.3-26-55.6-26.4-34.4-.3-55.2 23.4-59.7 29-.4.6-.5 1.3-.1 1.9.6.8 1.8.9 2.5.2C42.9 71.5 62.2 54.3 91 56c25.7 1.6 42.1 17.2 49 25.3 2.2 2.7 5.9 3.3 8.8 1.5z"/><path d="m22 88.8 65.2 54.3c4.4 3.7 10.8 3.8 15.3.2L170 90.7" style="fill:none;stroke:#FF9900;stroke-width:12;stroke-linecap:round;stroke-miterlimit:10"/><path fill="#FF9900" d="M109.6 106.9c3.6-2.2 4-7.3.7-10-2.9-2.3-6.8-4.7-11.9-5.7-13.4-2.6-23.6 6.4-26.4 9.2-.5.5-.6 1.2-.3 1.7.4.8 1.4 1 2.1.6 2.4-1.5 6.4-3.4 11.5-3.5 7.8-.2 13.7 3.9 17 7 2 1.8 5 2.1 7.3.7z"/></svg>
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
1
client/static/flame-icon.svg
Normal file
1
client/static/flame-icon.svg
Normal file
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 92.27 122.88" style="enable-background:new 0 0 92.27 122.88" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#EC6F59;} .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FAD15C;}</style><g><path class="st0" d="M18.61,54.89C15.7,28.8,30.94,10.45,59.52,0C42.02,22.71,74.44,47.31,76.23,70.89 c4.19-7.15,6.57-16.69,7.04-29.45c21.43,33.62,3.66,88.57-43.5,80.67c-4.33-0.72-8.5-2.09-12.3-4.13C10.27,108.8,0,88.79,0,69.68 C0,57.5,5.21,46.63,11.95,37.99C12.85,46.45,14.77,52.76,18.61,54.89L18.61,54.89z"/><path class="st1" d="M33.87,92.58c-4.86-12.55-4.19-32.82,9.42-39.93c0.1,23.3,23.05,26.27,18.8,51.14 c3.92-4.44,5.9-11.54,6.25-17.15c6.22,14.24,1.34,25.63-7.53,31.43c-26.97,17.64-50.19-18.12-34.75-37.72 C26.53,84.73,31.89,91.49,33.87,92.58L33.87,92.58z"/></g></svg>
|
After Width: | Height: | Size: 975 B |
@ -173,7 +173,13 @@ export const actions = {
|
||||
|
||||
export const mutations = {
|
||||
UPDATE_LIBRARY_ITEM(state, libraryItem) {
|
||||
Vue.set(state.libraryItemsCache, libraryItem.id, 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
|
||||
|
@ -194,6 +194,7 @@
|
||||
"HeaderSettingsDisplay": "Display",
|
||||
"HeaderSettingsExperimental": "Experimental Features",
|
||||
"HeaderSettingsGeneral": "General",
|
||||
"HeaderSettingsRatings": "Ratings",
|
||||
"HeaderSettingsScanner": "Scanner",
|
||||
"HeaderSettingsWebClient": "Web Client",
|
||||
"HeaderSleepTimer": "Sleep Timer",
|
||||
@ -349,6 +350,7 @@
|
||||
"LabelExplicit": "Explicit",
|
||||
"LabelExplicitChecked": "Explicit (checked)",
|
||||
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
|
||||
"LabelExplicitRating": "Explicit Rating",
|
||||
"LabelExportOPML": "Export OPML",
|
||||
"LabelFeedURL": "Feed URL",
|
||||
"LabelFetchingMetadata": "Fetching Metadata",
|
||||
@ -562,7 +564,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",
|
||||
|
@ -147,11 +147,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}
|
||||
@ -334,6 +344,7 @@ class Database {
|
||||
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 })
|
||||
}
|
||||
|
@ -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,62 @@ const ShareManager = require('../managers/ShareManager')
|
||||
class LibraryItemController {
|
||||
constructor() {}
|
||||
|
||||
async _getExpandedItemWithRatings(libraryItem, user) {
|
||||
const item = libraryItem.toOldJSONExpanded()
|
||||
|
||||
if (libraryItem.isBook) {
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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,15 +108,7 @@ class LibraryItemController {
|
||||
async findOne(req, res) {
|
||||
const includeEntities = (req.query.include || '').split(',')
|
||||
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
|
||||
}
|
||||
const item = await this._getExpandedItemWithRatings(req.libraryItem, req.user)
|
||||
|
||||
// Include users media progress
|
||||
if (includeEntities.includes('progress')) {
|
||||
@ -263,9 +312,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 this._getExpandedItemWithRatings(updatedLibraryItem, req.user)
|
||||
|
||||
res.json({
|
||||
updated: hasUpdates,
|
||||
libraryItem: req.libraryItem.toOldJSON()
|
||||
libraryItem: itemWithRatings
|
||||
})
|
||||
}
|
||||
|
||||
@ -1189,8 +1242,8 @@ class LibraryItemController {
|
||||
}
|
||||
}
|
||||
|
||||
if (req.path.includes('/play') || req.path.includes('/rate')) {
|
||||
// allow POST requests using /play and /play/:episodeId OR /rate
|
||||
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)
|
||||
@ -1218,14 +1271,54 @@ class LibraryItemController {
|
||||
const bookId = req.libraryItem.media.id
|
||||
const userId = req.user.id
|
||||
|
||||
await Database.models.userBookRating.upsert({ userId, bookId, rating })
|
||||
await Database.userBookRatingModel.upsert({ userId, bookId, rating })
|
||||
|
||||
res.status(200).json({ success: true })
|
||||
const updatedLibraryItem = await Database.libraryItemModel.getExpandedById(req.libraryItem.id)
|
||||
const itemWithRatings = await this._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) {
|
||||
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 this._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()
|
||||
|
||||
// Manually bind 'this' for all methods
|
||||
for (const methodName of Object.getOwnPropertyNames(LibraryItemController.prototype)) {
|
||||
if (methodName !== 'constructor' && typeof controller[methodName] === 'function') {
|
||||
controller[methodName] = controller[methodName].bind(controller)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = controller
|
||||
|
59
server/migrations/v2.25.4-add-user-book-explicit-ratings.js
Normal file
59
server/migrations/v2.25.4-add-user-book-explicit-ratings.js
Normal 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(
|
||||
'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
|
||||
})
|
||||
await transaction.commit()
|
||||
} catch (err) {
|
||||
await transaction.rollback()
|
||||
throw err
|
||||
}
|
||||
},
|
||||
down: async ({ context: queryInterface }) => {
|
||||
await queryInterface.dropTable('userBookExplicitRatings')
|
||||
}
|
||||
}
|
54
server/models/UserBookExplicitRating.js
Normal file
54
server/models/UserBookExplicitRating.js
Normal file
@ -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
|
@ -58,6 +58,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']
|
||||
@ -123,6 +128,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']
|
||||
|
||||
@ -233,6 +242,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,
|
||||
|
@ -126,6 +126,7 @@ class ApiRouter {
|
||||
this.router.get('/items/:id/ebook/:fileid?', LibraryItemController.middleware.bind(this), LibraryItemController.getEBookFile.bind(this))
|
||||
this.router.patch('/items/:id/ebook/:fileid/status', LibraryItemController.middleware.bind(this), LibraryItemController.updateEbookFileStatus.bind(this))
|
||||
this.router.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
|
||||
|
Loading…
Reference in New Issue
Block a user