mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-08-05 13:46:21 +02:00
Merge 9a94b9b0c3
into 32da0f1224
This commit is contained in:
commit
8b2751fa56
@ -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 ###
|
||||
|
@ -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: {
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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() {
|
||||
|
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>
|
114
client/components/ui/RatingInput.vue
Normal file
114
client/components/ui/RatingInput.vue
Normal 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>
|
@ -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() {
|
||||
|
@ -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'
|
||||
}
|
||||
})
|
||||
|
@ -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,
|
||||
|
88
client/cypress/tests/components/ui/RatingInput.cy.js
Normal file
88
client/cypress/tests/components/ui/RatingInput.cy.js
Normal 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
14473
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
},
|
||||
|
@ -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>
|
||||
|
@ -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 }
|
||||
|
2
client/static/audible.svg
Normal file
2
client/static/audible.svg
Normal 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 |
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 |
@ -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
|
||||
},
|
||||
|
@ -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
12
client/vite.config.js
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
@ -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
13
package-lock.json
generated
@ -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",
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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 })
|
||||
}
|
||||
|
@ -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
|
||||
|
174
server/migrations/v2.25.2-add-book-ratings.js
Normal file
174
server/migrations/v2.25.2-add-book-ratings.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
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
|
53
server/models/UserBookRating.js
Normal file
53
server/models/UserBookRating.js
Normal 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
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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)) {
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
132
test/server/migrations/v2.25.2-add-book-ratings.test.js
Normal file
132
test/server/migrations/v2.25.2-add-book-ratings.test.js
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in New Issue
Block a user