This commit is contained in:
peter1490 2025-07-29 21:36:10 +02:00 committed by GitHub
commit 8b2751fa56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 11663 additions and 4403 deletions

View File

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

View File

@ -78,7 +78,7 @@
</div>
<!-- Error widget -->
<ui-tooltip cy-id="ErrorTooltip" v-if="showError" :text="errorText" class="absolute bottom-4e left-0 z-10">
<ui-tooltip cy-id="errorTooltip" v-if="showError" :text="errorText" class="absolute bottom-4e left-0 z-10">
<div :style="{ height: 1.5 + 'em', width: 2.5 + 'em' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-symbols text-red-100 pr-1e" :style="{ fontSize: 0.875 + 'em' }">priority_high</span>
</div>
@ -137,7 +137,7 @@
<script>
import Vue from 'vue'
import MoreMenu from '@/components/widgets/MoreMenu'
import MoreMenu from '@/components/widgets/MoreMenu.vue'
export default {
props: {

View File

@ -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 && this.mediaType !== 'podcast') {
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()

View File

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

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 && !isPodcast" 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 && !this.isPodcast) {
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,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>

View File

@ -0,0 +1,114 @@
<template>
<div class="rating-input flex items-center" :aria-label="label" :class="{ 'read-only': readOnly }">
<div
v-for="star in 5"
:key="star"
class="star-container relative"
:data-star="star"
@mouseleave="handleMouseleave"
@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), 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>
</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,
default: 0
},
label: {
type: String,
default: 'Rating'
},
readOnly: {
type: Boolean,
default: false
},
icon: {
type: String,
default: 'star'
},
color: {
type: String,
default: '#f59e0b'
}
},
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;
}
.star-filled {
stroke-width: 1.5;
}
</style>

View File

@ -50,20 +50,33 @@
</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 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 class="flex flex-wrap mt-2 -mx-1 items-end">
<div :class="isPodcast ? 'w-full px-1' : '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="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="grow px-1 pt-6 mt-2 md:mt-0">
<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 v-if="!isPodcast" 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>
@ -72,7 +85,12 @@
</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: []
}
@ -109,6 +128,10 @@ export default {
}
},
computed: {
isPodcast() {
if (!this.libraryItem) return false
return this.libraryItem.mediaType === 'podcast'
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
@ -240,6 +263,7 @@ export default {
checkForChanges() {
var metadata = {}
for (const key in this.details) {
if (this.isPodcast && key === 'rating') continue
var newValue = this.details[key]
var oldValue = this.mediaMetadata[key]
// Key cleared out or key first populated
@ -285,6 +309,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

@ -1,11 +1,11 @@
const { defineConfig } = require("cypress")
const { defineConfig } = require('cypress')
module.exports = defineConfig({
component: {
devServer: {
framework: "nuxt",
bundler: "webpack"
framework: 'nuxt',
bundler: 'vite'
},
specPattern: "cypress/tests/**/*.cy.js"
specPattern: 'cypress/tests/**/*.cy.js'
}
})

View File

@ -1,4 +1,4 @@
import LazyBookCard from '@/components/cards/LazyBookCard'
import LazyBookCard from '@/components/cards/LazyBookCard.vue'
import Tooltip from '@/components/ui/Tooltip.vue'
import ExplicitIndicator from '@/components/widgets/ExplicitIndicator.vue'
import LoadingSpinner from '@/components/widgets/LoadingSpinner.vue'
@ -109,20 +109,19 @@ describe('LazyBookCard', () => {
cy.get('&explicitIndicator').should('not.exist')
cy.get('&line2').should('have.text', 'J. R. R. Tolkien')
cy.get('&line3').should('not.exist')
cy.get('seriesSequenceList').should('not.exist')
cy.get('&seriesSequenceList').should('not.exist')
cy.get('&booksInSeries').should('not.exist')
cy.get('&placeholderTitle').should('be.visible')
cy.get('&placeholderTitleText').should('have.text', 'The Fellowship of the Ring')
cy.get('&placeholderAuthor').should('be.visible')
cy.get('&placeholderAuthorText').should('have.text', 'J. R. R. Tolkien')
cy.get('&progressBar').should('be.hidden')
cy.get('&finishedProgressBar').should('not.exist')
cy.get('&loadingSpinner').should('not.exist')
cy.get('&seriesNameOverlay').should('not.exist')
cy.get('&errorTooltip').should('not.exist')
cy.get('&rssFeed').should('not.exist')
cy.get('&seriesSequence').should('not.exist')
cy.get('&podcastEpisdeNumber').should('not.exist')
cy.get('&podcastEpisodeNumber').should('not.exist')
// this should actually fail, since the height does not cover
// the detailBottom element, currently rendered outside the card's area,

View File

@ -0,0 +1,88 @@
import RatingInput from '@/components/ui/RatingInput.vue'
import FlameIcon from '@/components/ui/FlameIcon.vue'
describe('<RatingInput />', () => {
it('renders with initial value', () => {
cy.mount(RatingInput, {
propsData: {
value: 3.5
}
})
cy.get('.rating-input').should('be.visible')
cy.get('.star-filled').should('have.length', 5)
cy.get('span').should('contain.text', '3.5/5')
})
it('updates value on click', () => {
const onInput = cy.spy().as('onInput')
cy.mount(RatingInput, {
propsData: {
value: 0
},
listeners: {
input: onInput
}
})
cy.get('.star-container[data-star="4"]').click()
cy.get('@onInput').should('have.been.calledWith', 4)
})
it('handles half-star clicks', () => {
const onInput = cy.spy().as('onInput')
cy.mount(RatingInput, {
propsData: {
value: 0
},
listeners: {
input: onInput
}
})
// Clicking on the left half of the 3rd star
cy.get('.star-container[data-star="3"]').click('left')
cy.get('@onInput').should('have.been.calledWith', 2.5)
})
it('shows hover value on mousemove', () => {
cy.mount(RatingInput, {
propsData: {
value: 1
}
})
cy.get('.star-container[data-star="5"]').trigger('mousemove', 'center')
// After hover, the internal value should be 5, so the 5th star should be fully visible
cy.get('.star-filled').last().should('have.css', 'clip-path', 'inset(0px)')
})
it('is readonly when prop is set', () => {
const onInput = cy.spy().as('onInput')
cy.mount(RatingInput, {
propsData: {
value: 2,
readOnly: true
},
listeners: {
input: onInput
}
})
cy.get('.star-container[data-star="4"]').click()
cy.get('@onInput').should('not.have.been.called')
})
it('renders flame icons when specified', () => {
cy.mount(RatingInput, {
propsData: {
value: 4.5,
icon: 'flame'
},
stubs: {
'ui-flame-icon': FlameIcon
}
})
cy.get('svg > path[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"]').should('exist')
})
})

14473
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -36,12 +36,19 @@
"vuedraggable": "^2.24.3"
},
"devDependencies": {
"@cypress/webpack-dev-server": "~3.1.1",
"@nuxtjs/pwa": "^3.3.5",
"@tailwindcss/cli": "^4.0.14",
"@vitejs/plugin-vue2": "^2.3.3",
"css-loader": "^4.3.0",
"cypress": "^12.17.4",
"html-webpack-plugin": "^4.5.2",
"path-browserify": "^1.0.1",
"postcss": "^8.3.6",
"tailwindcss": "^4.0.13"
},
"optionalDependencies": {
"cypress": "^13.7.3"
"style-loader": "^2.0.0",
"tailwindcss": "^4.0.13",
"vite": "^5.4.19",
"webpack": "^4.46.0",
"webpack-dev-server": "^3.11.3"
}
}

View File

@ -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 || communityRatingDisabled" @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>
@ -245,6 +279,12 @@ export default {
}
},
watch: {
communityRatingDisabled(isDisabled) {
if (isDisabled && this.newServerSettings.enableCommunityRating) {
this.newServerSettings.enableCommunityRating = false
this.updateSettingsKey('enableCommunityRating', false)
}
},
serverSettings(newVal, oldVal) {
if (newVal && !oldVal) {
this.initServerSettings()
@ -252,6 +292,9 @@ export default {
}
},
computed: {
communityRatingDisabled() {
return !this.newServerSettings.enableRating && !this.newServerSettings.enableExplicitRating
},
serverSettings() {
return this.$store.state.serverSettings
},

View File

@ -32,11 +32,13 @@
</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">
<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
><span :key="index" v-if="index < seriesList.length - 1">, </span>
<template v-for="(_series, index) in seriesList" :key="_series.id">
<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 v-if="index < seriesList.length - 1">, </span>
</template>
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">{{ $getString('LabelByAuthor', [podcastAuthor]) }}</p>
@ -45,6 +47,37 @@
</p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
<!-- RATING SECTION -->
<div v-if="serverSettings.enableRating && !isPodcast" class="flex items-center space-x-4 mt-2">
<div class="flex items-center">
<ui-rating-input :value="myRating" :label="$strings.LabelYourRating" @input="updateRating" />
</div>
<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="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" :label="provider" :read-only="true" />
</div>
</div>
<!-- EXPLICIT RATING SECTION -->
<div v-if="serverSettings.enableExplicitRating && isExplicit && !isPodcast" 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>
<content-library-item-details :library-item="libraryItem" />
</div>
<div class="hidden md:block grow" />
@ -147,14 +180,19 @@
</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}`)
}
// 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
})
@ -162,11 +200,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
}
@ -182,13 +220,19 @@ export default {
episodeDownloadsQueued: [],
showBookmarksModal: false,
isDescriptionClamped: false,
showFullDescription: false
showFullDescription: false,
}
},
computed: {
libraryItem() {
return this.$store.state.libraries.libraryItemsCache[this.$route.params.id] || {}
},
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}`
},
@ -309,6 +353,24 @@ export default {
description() {
return this.mediaMetadata.description || ''
},
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
},
provider() {
return this.media.provider || null
},
userMediaProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
@ -433,6 +495,50 @@ export default {
return items
}
},
mounted() {
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
@ -594,9 +700,7 @@ export default {
},
libraryItemUpdated(libraryItem) {
if (libraryItem.id === this.libraryItemId) {
console.log('Item was updated', libraryItem)
this.libraryItem = libraryItem
this.$nextTick(this.checkDescriptionClamped)
this.$store.commit('libraries/UPDATE_LIBRARY_ITEM', libraryItem)
}
},
clearProgressClick() {
@ -777,36 +881,57 @@ export default {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
this.$store.commit('globals/setShareModal', this.mediaItemShare)
}
},
async updateRating(rating) {
try {
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) {
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>

View File

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

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

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

View File

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

View File

@ -198,6 +198,7 @@
"HeaderSettingsDisplay": "Display",
"HeaderSettingsExperimental": "Experimental Features",
"HeaderSettingsGeneral": "General",
"HeaderSettingsRatings": "Book Ratings",
"HeaderSettingsScanner": "Scanner",
"HeaderSettingsWebClient": "Web Client",
"HeaderSleepTimer": "Sleep Timer",
@ -362,6 +363,7 @@
"LabelExplicit": "Explicit",
"LabelExplicitChecked": "Explicit (checked)",
"LabelExplicitUnchecked": "Not Explicit (unchecked)",
"LabelExplicitRating": "Explicit Rating",
"LabelExportOPML": "Export OPML",
"LabelFeedURL": "Feed URL",
"LabelFetchingMetadata": "Fetching Metadata",
@ -535,6 +537,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",
@ -579,7 +582,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",

12
client/vite.config.js Normal file
View File

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

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:

13
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

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

@ -59,6 +59,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']
@ -124,6 +129,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']
@ -234,6 +243,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,

View File

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

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

View File

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

View File

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