Added server wide audiobook rating (admin only)

This commit is contained in:
Peter BALIVET 2025-06-27 11:02:42 +02:00
parent d21fe49ce2
commit 7c3504fe2b
10 changed files with 312 additions and 18 deletions

View File

@ -0,0 +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>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -21,9 +21,9 @@
<p>{{ $strings.MessageNoResults }}</p>
</div>
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper mt-4">
<template v-for="(res, index) in searchResults">
<cards-book-match-card :key="index" :book="res" :current-book-duration="currentBookDuration" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
</template>
<div v-for="(res, index) in searchResults" :key="index">
<cards-book-match-card :book="res" :current-book-duration="currentBookDuration" :is-podcast="isPodcast" :book-cover-aspect-ratio="bookCoverAspectRatio" @select="selectMatch" />
</div>
</div>
<div v-if="selectedMatchOrig" class="absolute top-0 left-0 w-full bg-bg h-full px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden">
<div class="flex mb-4">
@ -225,6 +225,16 @@
</div>
</div>
<div v-if="selectedMatchOrig.rating != null" class="flex items-center py-2">
<ui-checkbox v-model="selectedMatchUsage.rating" checkbox-bg="bg" @input="checkboxToggled" />
<div class="grow ml-4">
<ui-rating-input v-model="selectedMatch.rating" :disabled="!selectedMatchUsage.rating" :label="$strings.LabelRating" />
<p v-if="mediaMetadata.rating" class="text-xs ml-1 text-white/60">
{{ $strings.LabelCurrently }} <a :title="$strings.LabelClickToUseCurrentValue" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('rating', mediaMetadata.rating)">{{ mediaMetadata.rating }}/5</a>
</p>
</div>
</div>
<div class="flex items-center justify-end py-2">
<ui-btn color="bg-success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
</div>
@ -234,7 +244,12 @@
</template>
<script>
import UiRatingInput from '~/components/ui/RatingInput.vue'
export default {
components: {
UiRatingInput
},
props: {
processing: Boolean,
libraryItem: {
@ -270,6 +285,7 @@ export default {
asin: true,
isbn: true,
abridged: true,
rating: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
@ -452,6 +468,7 @@ export default {
asin: true,
isbn: true,
abridged: true,
rating: true,
// Podcast specific
itunesPageUrl: true,
itunesId: true,
@ -534,6 +551,9 @@ export default {
if (match.narrator && !Array.isArray(match.narrator)) {
match.narrator = match.narrator.split(',').map((g) => g.trim())
}
if (match.rating) {
match.rating = parseFloat(match.rating)
}
}
console.log('Select Match', match)
@ -590,6 +610,14 @@ export default {
}
}
if (this.selectedMatchUsage.rating && this.selectedMatchOrig.rating) {
updatePayload.provider_data = {
provider: this.provider,
providerId: this.selectedMatchOrig.asin || this.selectedMatchOrig.id,
rating: this.selectedMatchOrig.rating
}
}
return updatePayload
},
async submitMatchUpdate() {

View File

@ -0,0 +1,107 @@
<template>
<div class="rating-input-container">
<label v-if="label" class="px-1 text-sm font-semibold">{{ label }}</label>
<div
class="flex items-center"
@mouseleave="handleMouseleave"
>
<div
v-for="star in 5"
:key="star"
class="star-container relative"
:data-star="star"
@mousemove="handleMousemove"
@click="handleClick()"
>
<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">
<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>
</div>
</div>
</template>
<script>
export default {
props: {
value: {
type: Number,
default: 0
},
label: {
type: String,
default: ''
},
readOnly: {
type: Boolean,
default: false
}
},
data() {
return {
hoverValue: 0
}
},
computed: {
internalValue() {
return this.hoverValue > 0 ? this.hoverValue : this.value
},
displayValue() {
return this.value.toFixed(1)
}
},
methods: {
handleClick() {
if (this.readOnly) return
this.$emit('input', this.hoverValue)
},
handleMousemove(event) {
if (this.readOnly) return
const { left, width } = event.currentTarget.getBoundingClientRect()
const x = event.clientX - left
const star = parseInt(event.currentTarget.dataset.star)
const halfWidth = width / 2
this.hoverValue = x < halfWidth ? star - 0.5 : star
},
handleMouseleave() {
if (this.readOnly) return
this.hoverValue = 0
},
getClipPath(star) {
if (this.internalValue >= star) {
return 'inset(0 0 0 0)'
} else if (this.internalValue > star - 1 && this.internalValue < star) {
if (this.internalValue >= star - 0.5) {
return 'inset(0 50% 0 0)'
}
}
return 'inset(0 100% 0 0)'
}
}
}
</script>
<style scoped>
.star {
width: 24px;
height: 24px;
cursor: pointer;
}
.star.read-only {
cursor: default;
}
.star-empty {
fill: transparent;
stroke: #d1d5db;
stroke-width: 1.5;
}
.star-filled {
fill: #f59e0b;
stroke: #f59e0b;
stroke-width: 1.5;
}
</style>

View File

@ -50,29 +50,47 @@
</div>
<div class="flex flex-wrap mt-2 -mx-1">
<div class="w-full md:w-1/4 px-1">
<div class="w-full md:w-1/2 px-1">
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" :label="$strings.LabelPublisher" trim-whitespace @input="handleInputChange" />
</div>
<div class="w-1/2 md:w-1/4 px-1 mt-2 md:mt-0">
<div class="w-full md:w-1/2 px-1 mt-2 md:mt-0">
<ui-text-input-with-label ref="languageInput" v-model="details.language" :label="$strings.LabelLanguage" trim-whitespace @input="handleInputChange" />
</div>
<div class="grow px-1 pt-6 mt-2 md:mt-0">
</div>
<div class="flex flex-wrap mt-2 -mx-1 items-end">
<div class="w-full md:w-1/2 px-1">
<div class="flex -mx-1">
<div class="w-1/2 px-1">
<div class="flex justify-center">
<ui-checkbox v-model="details.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" @input="handleInputChange" />
</div>
</div>
<div class="grow px-1 pt-6 mt-2 md:mt-0">
<div class="w-1/2 px-1">
<div class="flex justify-center">
<ui-checkbox v-model="details.abridged" :label="$strings.LabelAbridged" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" @input="handleInputChange" />
</div>
</div>
</div>
</div>
<div class="w-full md:w-1/2 px-1 mt-2 md:mt-0">
<div class="flex justify-center items-center">
<label class="px-1 text-sm font-semibold mr-2">{{ $strings.LabelRating }}</label>
<ui-rating-input v-model="details.rating" @input="handleInputChange" />
</div>
</div>
</div>
</form>
</div>
</template>
<script>
import UiRatingInput from '~/components/ui/RatingInput.vue'
export default {
components: {
UiRatingInput
},
props: {
libraryItem: {
type: Object,
@ -95,7 +113,8 @@ export default {
asin: null,
genres: [],
explicit: false,
abridged: false
abridged: false,
rating: 0
},
newTags: []
}
@ -285,6 +304,7 @@ export default {
this.details.asin = this.mediaMetadata.asin || null
this.details.explicit = !!this.mediaMetadata.explicit
this.details.abridged = !!this.mediaMetadata.abridged
this.details.rating = this.mediaMetadata.rating || 0
this.newTags = [...(this.media.tags || [])]
},
submitForm() {

View File

@ -32,6 +32,8 @@
</div>
</h1>
<p v-if="bookTitle" class="text-gray-200 text-3xl md:text-4xl leading-snug">{{ bookTitle }}</p>
<p v-if="bookSubtitle" class="text-gray-200 text-xl md:text-2xl">{{ bookSubtitle }}</p>
<template v-for="(_series, index) in seriesList">
@ -45,6 +47,19 @@
</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 v-if="userRating > 0" class="flex items-center">
<ui-rating-input :value="userRating" :label="$strings.LabelYourRating" :read-only="true" />
</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" />
<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" />
</div>
</div>
<content-library-item-details :library-item="libraryItem" />
</div>
<div class="hidden md:block grow" />
@ -147,7 +162,12 @@
</template>
<script>
import UiRatingInput from '~/components/ui/RatingInput.vue'
export default {
components: {
UiRatingInput
},
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
@ -309,6 +329,17 @@ export default {
description() {
return this.mediaMetadata.description || ''
},
userRating() {
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
},
userMediaProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},

View File

@ -518,6 +518,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",

View File

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

View File

@ -0,0 +1,58 @@
const { DataTypes } = require('sequelize')
module.exports = {
up: async ({ context: queryInterface }) => {
const transaction = await queryInterface.sequelize.transaction()
try {
await queryInterface.addColumn(
'books',
'rating',
{
type: DataTypes.FLOAT
},
{ transaction }
)
await queryInterface.addColumn(
'books',
'providerRating',
{
type: DataTypes.FLOAT
},
{ transaction }
)
await queryInterface.addColumn(
'books',
'provider',
{
type: DataTypes.STRING
},
{ transaction }
)
await queryInterface.addColumn(
'books',
'providerId',
{
type: DataTypes.STRING
},
{ transaction }
)
await transaction.commit()
} catch (err) {
await transaction.rollback()
throw err
}
},
down: async ({ context: queryInterface }) => {
const transaction = await queryInterface.sequelize.transaction()
try {
await queryInterface.removeColumn('books', 'rating', { transaction })
await queryInterface.removeColumn('books', 'providerRating', { transaction })
await queryInterface.removeColumn('books', 'provider', { transaction })
await queryInterface.removeColumn('books', 'providerId', { transaction })
await transaction.commit()
} catch (err) {
await transaction.rollback()
throw err
}
}
}

View File

@ -130,6 +130,15 @@ class Book extends Model {
this.authors
/** @type {import('./Series')[]} - optional if expanded */
this.series
/** @type {number} */
this.rating
/** @type {number} */
this.providerRating
/** @type {string} */
this.provider
/** @type {string} */
this.providerId
}
/**
@ -159,6 +168,11 @@ class Book extends Model {
coverPath: DataTypes.STRING,
duration: DataTypes.FLOAT,
rating: DataTypes.FLOAT,
providerRating: DataTypes.FLOAT,
provider: DataTypes.STRING,
providerId: DataTypes.STRING,
narrators: DataTypes.JSON,
audioFiles: DataTypes.JSON,
ebookFile: DataTypes.JSON,
@ -357,7 +371,8 @@ class Book extends Model {
asin: this.asin,
language: this.language,
explicit: !!this.explicit,
abridged: !!this.abridged
abridged: !!this.abridged,
rating: this.rating
}
}
@ -405,6 +420,10 @@ class Book extends Model {
this.abridged = !!payload.metadata.abridged
hasUpdates = true
}
if (payload.metadata.rating !== undefined && this.rating !== payload.metadata.rating) {
this.rating = 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 +434,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 +603,8 @@ class Book extends Model {
asin: this.asin,
language: this.language,
explicit: this.explicit,
abridged: this.abridged
abridged: this.abridged,
rating: this.rating
}
}
@ -591,7 +626,8 @@ class Book extends Model {
asin: this.asin,
language: this.language,
explicit: this.explicit,
abridged: this.abridged
abridged: this.abridged,
rating: this.rating
}
}
@ -603,6 +639,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.rating
return oldMetadataJSON
}
@ -680,7 +717,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
}
}
}

View File

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