Merge pull request #2954 from mikiher/series-progress-fixes

Fix series and collapsed series progress to be consistent and show average of book series progress
This commit is contained in:
advplyr 2024-05-12 13:37:10 -05:00 committed by GitHub
commit dc0eaa32c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 516 additions and 181 deletions

View File

@ -6,9 +6,12 @@
</div>
<div class="flex items-start mb-6 lg:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
<div class="min-w-0">
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
{{ title }}
</nuxt-link>
<div class="flex items-center">
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
{{ title }}
</nuxt-link>
<widgets-explicit-indicator v-if="isExplicit" />
</div>
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
<span class="material-icons text-sm">person</span>
<div class="flex items-center">
@ -18,7 +21,6 @@
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">,&nbsp;</span></nuxt-link>
</div>
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
</div>
</div>
@ -136,7 +138,7 @@ export default {
return this.streamLibraryItem?.mediaType === 'music'
},
isExplicit() {
return this.mediaMetadata.explicit || false
return !!this.mediaMetadata.explicit
},
mediaMetadata() {
return this.media.metadata || {}

View File

@ -29,7 +29,7 @@
</div>
<div v-else class="px-4 flex-grow">
<h1>
<div class="flex items-center">{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" /></div>
<div class="flex items-center">{{ book.title }}<widgets-explicit-indicator v-if="book.explicit" /></div>
</h1>
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>

View File

@ -1,128 +1,124 @@
<template>
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="absolute rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<!-- When cover image does not fill -->
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
<!-- Alternative bookshelf title/author/sort -->
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<div cy-id="detailBottom" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
<p ref="displayTitle" class="truncate">{{ displayTitle }}</p>
<widgets-explicit-indicator :explicit="isExplicit" />
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
</ui-tooltip>
</div>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || '&nbsp;' }}</p>
<p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
</div>
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #78350f">
<div cy-id="seriesSequenceList" v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #78350f">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
</div>
<div v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #cd9d49dd">
<div cy-id="booksInSeries" v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #cd9d49dd">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
</div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
</div>
<!-- Cover Image -->
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<img cy-id="coverImage" v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Placeholder Cover Title & Author -->
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">
{{ titleCleaned }}
</p>
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
<!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- Finished progress bar for collapsed series -->
<div v-else-if="booksInSeries && seriesIsFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- No progress shown for podcasts (unless showing podcast episode) -->
<div cy-id="progressBar" v-if="!isPodcast || episodeProgress" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- Overlay is not shown if collapsing series in library -->
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
<div cy-id="overlay" v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded md:block" :class="overlayWrapperClasslist">
<div cy-id="playButton" v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
</div>
</div>
<div v-show="showReadButton" class="h-full flex items-center justify-center pointer-events-none">
<div cy-id="readButton" v-show="showReadButton" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="clickReadEBook">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
</div>
</div>
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
<div cy-id="editButton" v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
</div>
<!-- Radio button -->
<div v-if="!isAuthorBookshelfView" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
<div cy-id="selectedRadioButton" v-if="!isAuthorBookshelfView" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
</div>
<!-- More Menu Icon -->
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<div cy-id="moreButton" ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div>
<div v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }">
<div cy-id="ebookFormat" v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }">
<span class="text-white/80" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ ebookFormat }}</span>
</div>
</div>
<!-- Processing/loading spinner overlay -->
<div v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
<div cy-id="loadingSpinner" v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
<widgets-loading-spinner size="la-lg" />
</div>
<!-- Series name overlay -->
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
<div cy-id="seriesNameOverlay" v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
<p v-if="seriesName" class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ seriesName }}</p>
</div>
<!-- Error widget -->
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0 z-10">
<ui-tooltip cy-id="ErrorTooltip" v-if="showError" :text="errorText" class="absolute bottom-4 left-0 z-10">
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div>
</ui-tooltip>
<div v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
</div>
<!-- Series sequence -->
<div v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<div cy-id="seriesSequence" v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
</div>
<!-- Podcast Episode # -->
<div v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<div cy-id="podcastEpisodeNumber" v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
</p>
</div>
<!-- Podcast Num Episodes -->
<div v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
</div>
<!-- Podcast Num Episodes -->
<div v-else-if="numEpisodesIncomplete && !isHovering && !isSelectionMode" class="absolute rounded-full bg-yellow-400 text-black font-semibold box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<div cy-id="numEpisodesIncomplete" v-else-if="numEpisodesIncomplete && !isHovering && !isSelectionMode" class="absolute rounded-full bg-yellow-400 text-black font-semibold box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
</div>
</div>
@ -343,11 +339,22 @@ export default {
if (!this.userProgress || this.userProgress.progress) return false
return this.userProgress.ebookProgress > 0
},
seriesProgressPercent() {
if (!this.libraryItemIdsInSeries.length) return 0
let progressPercent = 0
const useEBookProgress = this.useEBookProgress
this.libraryItemIdsInSeries.forEach((lid) => {
const progress = this.store.getters['user/getUserMediaProgress'](lid)
if (progress) progressPercent += progress.isFinished ? 1 : useEBookProgress ? progress.ebookProgress || 0 : progress.progress || 0
})
return progressPercent / this.libraryItemIdsInSeries.length
},
userProgressPercent() {
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0
return Math.max(Math.min(1, progressPercent), 0)
},
itemIsFinished() {
if (this.booksInSeries) return this.seriesIsFinished
return this.userProgress ? !!this.userProgress.isFinished : false
},
seriesIsFinished() {

View File

@ -119,9 +119,13 @@ export default {
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
},
seriesPercentInProgress() {
let totalFinishedAndInProgress = this.seriesBooksFinished.length
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
if (!this.books.length) return 0
let progressPercent = 0
this.seriesBookProgress.forEach((progress) => {
progressPercent += progress.isFinished ? 1 : progress.progress || 0
})
progressPercent /= this.books.length
return Math.min(1, Math.max(0, progressPercent))
},
isSeriesFinished() {
return this.books.length === this.seriesBooksFinished.length

View File

@ -20,7 +20,7 @@
<td class="px-4">
<div class="flex items-center">
<nuxt-link :to="`/item/${downloadQueued.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ downloadQueued.podcastTitle }}</nuxt-link>
<widgets-explicit-indicator :explicit="downloadQueued.podcastExplicit" />
<widgets-explicit-indicator v-if="downloadQueued.podcastExplicit" />
</div>
</td>
<td>

View File

@ -1,5 +1,5 @@
<template>
<ui-tooltip v-if="explicit" :text="$strings.LabelExplicit" direction="top">
<ui-tooltip :text="$strings.LabelExplicit" direction="top">
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
<path
fill="white"
@ -40,9 +40,7 @@
<script>
export default {
props: {
explicit: Boolean
},
props: {},
data() {
return {}
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

View File

@ -4,7 +4,6 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="stylesheet" href="tailwind.compiled.css">
<title>Components App</title>
</head>
<body class="text-white bg-bg">

View File

@ -0,0 +1,342 @@
import LazyBookCard from '@/components/cards/LazyBookCard'
import Tooltip from '@/components/ui/Tooltip.vue'
import ExplicitIndicator from '@/components/widgets/ExplicitIndicator.vue'
import LoadingSpinner from '@/components/widgets/LoadingSpinner.vue'
import { Constants } from '@/plugins/constants'
function createMountOptions() {
const book = {
id: '1',
ino: '281474976785140',
libraryId: 'library-123',
folderId: 'folder-123',
path: '/path/to/book',
relPath: 'book',
isFile: false,
mtimeMs: 1689017292016,
ctimeMs: 1689017292016,
birthtimeMs: 1689017281555,
addedAt: 1700154928492,
updatedAt: 1713300533345,
isMissing: false,
isInvalid: false,
mediaType: 'book',
media: {
id: 'book1',
metadata: {
title: 'The Fellowship of the Ring',
titleIgnorePrefix: 'Fellowship of the Ring',
subtitle: 'LOTR, Book 1',
authorName: 'J. R. R. Tolkien',
authorNameLF: 'Tolkien, J. R. R.',
narratorName: 'Andy Sirkis',
genres: ['Science Fiction & Fantasy'],
publishedYear: '2017',
publishedDate: null,
publisher: 'Book Publisher',
description: 'Book Description',
isbn: null,
asin: 'B075LXMLNV',
language: 'English',
explicit: false,
abridged: false
},
coverPath: null,
tags: ['Fantasy', 'Adventure'],
numTracks: 1,
numAudioFiles: 1,
numChapters: 31,
duration: 64410,
size: 511206878
},
numFiles: 4,
size: 511279587
}
const propsData = {
index: 0,
bookMount: book,
bookCoverAspectRatio: 1,
bookshelfView: Constants.BookshelfView.DETAIL,
continueListeningShelf: false,
filterBy: null,
width: 192,
height: 192,
sortingIgnorePrefix: false,
orderBy: null
}
const stubs = {
'ui-tooltip': Tooltip,
'widgets-explicit-indicator': ExplicitIndicator,
'widgets-loading-spinner': LoadingSpinner
}
const mocks = {
$config: {
routerBasePath: 'https://my.server.com'
},
$store: {
commit: () => {},
getters: {
'user/getUserCanUpdate': true,
'user/getUserCanDelete': true,
'user/getUserCanDownload': true,
'user/getIsAdminOrUp': true,
'user/getUserMediaProgress': (id) => null,
'libraries/getLibraryProvider': () => 'audible.us',
'globals/getLibraryItemCoverSrc': () => 'https://my.server.com/book_placeholder.jpg',
getLibraryItemsStreaming: () => null,
getIsMediaQueued: () => false,
getIsStreamingFromDifferentLibrary: () => false
},
state: {
libraries: {
currentLibraryId: 'library-123'
},
processingBatch: false,
serverSettings: {
dateFormat: 'MM/dd/yyyy'
}
}
}
}
return { propsData, stubs, mocks }
}
describe('LazyBookCard', () => {
let mountOptions = null
beforeEach(() => {
mountOptions = createMountOptions()
// cy.intercept(
// 'https://my.server.com/**/*',
// { middleware: true },
// (req) => {
// req.on('before:response', (res) => {
// // force all API responses to not be cached
// res.headers['cache-control'] = 'no-store'
// })
// }
// )
})
before(() => {
// Put placeholder image is in the browser cache
mountOptions = createMountOptions()
cy.intercept('https://my.server.com/book_placeholder.jpg', { fixture: 'images/book_placeholder.jpg' }).as('bookCover')
cy.mount(LazyBookCard, mountOptions)
cy.wait('@bookCover')
// Put cover1 (aspect ratio 1.6) image in the browser cache
mountOptions = createMountOptions()
mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover1.jpg'
cy.intercept('https://my.server.com/cover1.jpg', { fixture: 'images/cover1.jpg' }).as('bookCover1')
cy.mount(LazyBookCard, mountOptions)
cy.wait('@bookCover1')
// Put cover2 (aspect ratio 1) image in the browser cache
mountOptions = createMountOptions()
mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover2.jpg'
cy.intercept('https://my.server.com/cover2.jpg', { fixture: 'images/cover2.jpg' }).as('bookCover2')
cy.mount(LazyBookCard, mountOptions)
cy.wait('@bookCover2')
})
it('renders the component correctly', () => {
cy.mount(LazyBookCard, mountOptions)
cy.get('&titleImageNotReady').should('be.hidden')
cy.get('&coverImage').should('have.css', 'opacity', '1')
cy.get('&coverBg').should('be.hidden')
cy.get('&overlay').should('be.hidden')
cy.get('&detailBottom').should('be.visible')
cy.get('&title').should('have.text', 'The Fellowship of the Ring')
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('&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')
// this should actually fail, since the height does not cover
// the detailBottom element, currently rendered outside the card's area,
// and requires complex layout calculations outside of the component.
// todo: fix the component to render the detailBottom element inside the card's area
cy.get('#book-card-0').should(($el) => {
const width = $el.width()
const height = $el.height()
expect(width).to.be.closeTo(mountOptions.propsData.width, 0.01)
expect(height).to.be.closeTo(mountOptions.propsData.height, 0.01)
})
})
it('shows overlay on mouseover', () => {
cy.mount(LazyBookCard, mountOptions)
cy.get('#book-card-0').trigger('mouseover')
cy.get('&titleImageNotReady').should('be.hidden')
cy.get('&overlay').should('be.visible')
cy.get('&playButton').should('be.visible')
cy.get('&readButton').should('be.hidden')
cy.get('&editButton').should('be.visible')
cy.get('&selectedRadioButton').should('be.visible').and('have.text', 'radio_button_unchecked')
cy.get('&moreButton').should('be.visible')
cy.get('&ebookFormat').should('not.exist')
})
it('routes to item page when clicked', () => {
mountOptions.mocks.$router = { push: cy.stub().as('routerPush') }
cy.mount(LazyBookCard, mountOptions)
cy.get('#book-card-0').click()
cy.get('@routerPush').should('have.been.calledOnceWithExactly', '/item/1')
})
it('shows titleImageNotReady and sets opacity 0 on coverImage when image not ready', () => {
cy.mount(LazyBookCard, mountOptions)
cy.get('&titleImageNotReady').should('be.visible')
cy.get('&coverImage').should('have.css', 'opacity', '0')
})
it('shows coverBg when coverImage has different aspect ratio', () => {
mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover1.jpg'
cy.mount(LazyBookCard, mountOptions)
cy.get('&coverBg').should('be.visible')
cy.get('&coverImage').should('have.class', 'object-contain')
})
it('hides coverBg when coverImage has same aspect ratio', () => {
mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover2.jpg'
cy.mount(LazyBookCard, mountOptions)
cy.get('&coverBg').should('be.hidden')
cy.get('&coverImage').should('have.class', 'object-fill')
})
// The logic for displaying placeholder title and author seems incorrect.
// It is currently based on existence of coverPath, but should be based weater the actual cover image is placeholder or not.
// todo: fix the logic to display placeholder title and author based on the actual cover image.
it('hides placeholderTitle and placeholderAuthor when book has cover', () => {
mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/cover1.jpg'
mountOptions.propsData.bookMount.media.coverPath = 'cover1.jpg'
cy.mount(LazyBookCard, mountOptions)
cy.get('&placeholderTitle').should('not.exist')
cy.get('&placeholderAuthor').should('not.exist')
})
it('hides detailBottom when bookShelfView is STANDARD', () => {
mountOptions.propsData.bookshelfView = Constants.BookshelfView.STANDARD
cy.mount(LazyBookCard, mountOptions)
cy.get('&detailBottom').should('not.exist')
})
it('shows explicit indicator when book is explicit', () => {
mountOptions.propsData.bookMount.media.metadata.explicit = true
cy.mount(LazyBookCard, mountOptions)
cy.get('&explicitIndicator').should('be.visible')
})
describe('when collapsedSeries is present', () => {
beforeEach(() => {
mountOptions.propsData.bookMount.collapsedSeries = {
id: 'series-123',
name: 'The Lord of the Rings',
nameIgnorePrefix: 'Lord of the Rings',
numBooks: 3,
libraryItemIds: ['1', '2', '3']
}
})
it('shows the collpased series', () => {
cy.mount(LazyBookCard, mountOptions)
cy.get('&seriesSequenceList').should('not.exist')
cy.get('&booksInSeries').should('be.visible').and('have.text', '3')
cy.get('&title').should('be.visible').and('have.text', 'The Lord of the Rings')
cy.get('&line2').should('be.visible').and('have.text', '\u00a0')
cy.get('&progressBar').should('be.hidden')
})
it('shows the seriesNameOverlay on mouseover', () => {
mountOptions.propsData.bookMount.media.metadata.series = {
id: 'series-456',
name: 'Middle Earth Chronicles',
sequence: 1
}
cy.mount(LazyBookCard, mountOptions)
cy.get('#book-card-0').trigger('mouseover')
cy.get('&seriesNameOverlay').should('be.visible').and('have.text', 'Middle Earth Chronicles')
})
it('shows the seriesSequenceList when collapsed series has a sequence list', () => {
mountOptions.propsData.bookMount.collapsedSeries.seriesSequenceList = '1-3'
cy.mount(LazyBookCard, mountOptions)
cy.get('&seriesSequenceList').should('be.visible').and('have.text', '#1-3')
cy.get('&booksInSeries').should('not.exist')
})
it('routes to the series page when clicked', () => {
mountOptions.mocks.$router = { push: cy.stub().as('routerPush') }
cy.mount(LazyBookCard, mountOptions)
cy.get('#book-card-0').click()
cy.get('@routerPush').should('have.been.calledOnceWithExactly', '/library/library-123/series/series-123')
})
it('shows the series progress bar when series has progress', () => {
mountOptions.mocks.$store.getters['user/getUserMediaProgress'] = (id) => {
switch (id) {
case '1':
return { isFinished: true }
case '2':
return { progress: 0.5 }
default:
return null
}
}
cy.mount(LazyBookCard, mountOptions)
cy.get('&progressBar')
.should('be.visible')
.and('have.class', 'bg-yellow-400')
.and(($el) => {
const width = $el.width()
expect(width).to.be.closeTo(((1 + 0.5) / 3) * mountOptions.propsData.width, 0.01)
})
})
it('shows full green progress bar when all books are finished', () => {
mountOptions.mocks.$store.getters['user/getUserMediaProgress'] = (id) => {
return { isFinished: true }
}
cy.mount(LazyBookCard, mountOptions)
cy.get('&progressBar')
.should('be.visible')
.and('have.class', 'bg-success')
.and(($el) => {
const width = $el.width()
expect(width).to.be.equal(mountOptions.propsData.width)
})
})
})
})

View File

@ -26,7 +26,7 @@ describe('LazySeriesCard', () => {
isCategorized: false,
seriesMount: series,
sortingIgnorePrefix: false,
orderBy: 'addedAt',
orderBy: 'addedAt'
}
const stubs = {
@ -126,7 +126,7 @@ describe('LazySeriesCard', () => {
.and('have.class', 'bg-yellow-400')
.and(($el) => {
const width = $el.width()
expect(width).to.be.closeTo((2 / 3) * propsData.width, 0.01)
expect(width).to.be.closeTo(((1 + 0.5) / 3) * propsData.width, 0.01)
})
})
@ -137,7 +137,9 @@ describe('LazySeriesCard', () => {
...mocks.$store,
getters: {
...mocks.$store.getters,
'user/getUserMediaProgress': (id) => { return { isFinished: true } }
'user/getUserMediaProgress': (id) => {
return { isFinished: true }
}
}
}
}
@ -212,5 +214,4 @@ describe('LazySeriesCard', () => {
cy.get('&detailBottomDisplayTitle').should('have.text', 'Lord of the Rings')
})
})
})

View File

@ -27,7 +27,7 @@
<h1 class="text-2xl md:text-3xl font-semibold">
<div class="flex items-center">
{{ title }}
<widgets-explicit-indicator :explicit="isExplicit" />
<widgets-explicit-indicator v-if="isExplicit" />
<widgets-abridged-indicator v-if="isAbridged" />
</div>
</h1>

View File

@ -16,7 +16,7 @@
<div class="flex-grow px-2">
<div class="flex items-center">
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcastTitle }}</nuxt-link>
<widgets-explicit-indicator :explicit="episode.podcastExplicit" />
<widgets-explicit-indicator v-if="episode.podcastExplicit" />
</div>
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
</div>
@ -25,7 +25,7 @@
<div class="hidden md:block">
<div class="flex items-center">
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcastTitle }}</nuxt-link>
<widgets-explicit-indicator :explicit="episode.podcastExplicit" />
<widgets-explicit-indicator v-if="episode.podcastExplicit" />
</div>
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
</div>

View File

@ -18,7 +18,7 @@
<div class="flex" @click.stop>
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
</div>
<widgets-explicit-indicator :explicit="episode.podcast.metadata.explicit" />
<widgets-explicit-indicator v-if="episode.podcast.metadata.explicit" />
</div>
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
</div>
@ -29,7 +29,7 @@
<div class="flex" @click.stop>
<nuxt-link :to="`/item/${episode.libraryItemId}`" class="text-sm text-gray-200 hover:underline">{{ episode.podcast.metadata.title }}</nuxt-link>
</div>
<widgets-explicit-indicator :explicit="episode.podcast.metadata.explicit" />
<widgets-explicit-indicator v-if="episode.podcast.metadata.explicit" />
</div>
<p class="text-xs text-gray-300 mb-1">{{ $dateDistanceFromNow(episode.publishedAt) }}</p>
</div>

View File

@ -21,7 +21,7 @@
<div class="flex-grow pl-4 max-w-2xl">
<div class="flex items-center">
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
<widgets-explicit-indicator :explicit="podcast.explicit" />
<widgets-explicit-indicator v-if="podcast.explicit" />
<widgets-already-in-library-indicator :already-in-library="podcast.alreadyInLibrary" />
</div>
<p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>

View File

@ -7,7 +7,7 @@ const { asciiOnlyToLowerCase } = require('../index')
module.exports = {
/**
* User permissions to restrict books for explicit content & tags
* @param {import('../../objects/user/User')} user
* @param {import('../../objects/user/User')} user
* @returns {{ bookWhere:Sequelize.WhereOptions, replacements:object }}
*/
getUserPermissionBookWhereQuery(user) {
@ -25,9 +25,11 @@ module.exports = {
if (user.permissions.selectedTagsNotAccessible) {
bookWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), 0))
} else {
bookWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), {
[Sequelize.Op.gte]: 1
}))
bookWhere.push(
Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected))`), {
[Sequelize.Op.gte]: 1
})
)
}
}
return {
@ -39,8 +41,8 @@ module.exports = {
/**
* When collapsing series and filtering by progress
* different where options are required
*
* @param {string} value
*
* @param {string} value
* @returns {Sequelize.WhereOptions}
*/
getCollapseSeriesMediaProgressFilter(value) {
@ -90,8 +92,8 @@ module.exports = {
/**
* Get where options for Book model
* @param {string} group
* @param {[string]} value
* @param {string} group
* @param {[string]} value
* @returns {object} { Sequelize.WhereOptions, string[] }
*/
getMediaGroupQuery(group, value) {
@ -170,7 +172,7 @@ module.exports = {
Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), 0),
{
'$mediaProgresses.isFinished$': true,
'ebookFile': {
ebookFile: {
[Sequelize.Op.not]: null
}
}
@ -232,8 +234,8 @@ module.exports = {
/**
* Get sequelize order
* @param {string} sortBy
* @param {boolean} sortDesc
* @param {string} sortBy
* @param {boolean} sortDesc
* @param {boolean} collapseseries
* @returns {Sequelize.order}
*/
@ -278,18 +280,14 @@ module.exports = {
* When collapsing series get first book in each series
* to know which books to exclude from primary query.
* Additionally use this query to get the number of books in each series
*
* @param {Sequelize.ModelStatic} bookFindOptions
* @param {Sequelize.WhereOptions} seriesWhere
*
* @param {Sequelize.ModelStatic} bookFindOptions
* @param {Sequelize.WhereOptions} seriesWhere
* @returns {object} { booksToExclude, bookSeriesToInclude }
*/
async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) {
const allSeries = await Database.seriesModel.findAll({
attributes: [
'id',
'name',
[Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks']
],
attributes: ['id', 'name', [Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks']],
distinct: true,
subQuery: false,
where: seriesWhere,
@ -304,23 +302,22 @@ module.exports = {
required: true
}
],
order: [
Sequelize.literal('CAST(`books.bookSeries.sequence` AS FLOAT) ASC NULLS LAST')
]
order: [Sequelize.literal('CAST(`books.bookSeries.sequence` AS FLOAT) ASC NULLS LAST')]
})
const bookSeriesToInclude = []
const booksToInclude = []
let booksToExclude = []
allSeries.forEach(s => {
allSeries.forEach((s) => {
let found = false
for (let book of s.books) {
if (!found && !booksToInclude.includes(book.id)) {
booksToInclude.push(book.id)
bookSeriesToInclude.push({
id: book.bookSeries.id,
numBooks: s.dataValues.numBooks
numBooks: s.dataValues.numBooks,
libraryItemIds: s.books?.map((b) => b.libraryItem.id) || []
})
booksToExclude = booksToExclude.filter(bid => bid !== book.id)
booksToExclude = booksToExclude.filter((bid) => bid !== book.id)
found = true
} else if (!booksToExclude.includes(book.id) && !booksToInclude.includes(book.id)) {
booksToExclude.push(book.id)
@ -332,16 +329,16 @@ module.exports = {
/**
* Get library items for book media type using filter and sort
* @param {string} libraryId
* @param {string} libraryId
* @param {[oldUser]} user
* @param {[string]} filterGroup
* @param {[string]} filterValue
* @param {string} sortBy
* @param {string} sortDesc
* @param {[string]} filterGroup
* @param {[string]} filterValue
* @param {string} sortBy
* @param {string} sortDesc
* @param {boolean} collapseseries
* @param {string[]} include
* @param {number} limit
* @param {number} offset
* @param {number} limit
* @param {number} offset
* @param {boolean} isHomePage for home page shelves
* @returns {object} { libraryItems:LibraryItem[], count:number }
*/
@ -363,15 +360,11 @@ module.exports = {
let bookAttributes = null
if (sortBy === 'media.metadata.authorNameLF') {
bookAttributes = {
include: [
[Sequelize.literal(`(SELECT group_concat(a.lastFirst, ", ") FROM authors AS a, bookAuthors as ba WHERE ba.authorId = a.id AND ba.bookId = book.id)`), 'author_name']
]
include: [[Sequelize.literal(`(SELECT group_concat(a.lastFirst, ", ") FROM authors AS a, bookAuthors as ba WHERE ba.authorId = a.id AND ba.bookId = book.id)`), 'author_name']]
}
} else if (sortBy === 'media.metadata.authorName') {
bookAttributes = {
include: [
[Sequelize.literal(`(SELECT group_concat(a.name, ", ") FROM authors AS a, bookAuthors as ba WHERE ba.authorId = a.id AND ba.bookId = book.id)`), 'author_name']
]
include: [[Sequelize.literal(`(SELECT group_concat(a.name, ", ") FROM authors AS a, bookAuthors as ba WHERE ba.authorId = a.id AND ba.bookId = book.id)`), 'author_name']]
}
}
@ -386,9 +379,7 @@ module.exports = {
model: Database.seriesModel,
attributes: ['id', 'name', 'nameIgnorePrefix']
},
order: [
['createdAt', 'ASC']
],
order: [['createdAt', 'ASC']],
separate: true
}
@ -399,9 +390,7 @@ module.exports = {
model: Database.authorModel,
attributes: ['id', 'name']
},
order: [
['createdAt', 'ASC']
],
order: [['createdAt', 'ASC']],
separate: true
}
@ -427,7 +416,7 @@ module.exports = {
}
} else if (filterGroup === 'ebooks' && filterValue === 'no-supplementary') {
libraryItemWhere['libraryFiles'] = {
[Sequelize.Op.notLike]: Sequelize.literal(`\'%"isSupplementary":true%\'`),
[Sequelize.Op.notLike]: Sequelize.literal(`\'%"isSupplementary":true%\'`)
}
} else if (filterGroup === 'missing' && filterValue === 'authors') {
authorInclude = {
@ -496,7 +485,7 @@ module.exports = {
})
} else if (filterGroup === 'recent') {
libraryItemWhere['createdAt'] = {
[Sequelize.Op.gte]: new Date(new Date() - (60 * 24 * 60 * 60 * 1000)) // 60 days ago
[Sequelize.Op.gte]: new Date(new Date() - 60 * 24 * 60 * 60 * 1000) // 60 days ago
}
}
@ -551,9 +540,9 @@ module.exports = {
// When collapsing series and sorting by title then use the series name instead of the book title
// for this set an attribute "display_title" to use in sorting
if (global.ServerSettings.sortingIgnorePrefix) {
bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.nameIgnorePrefix FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map(v => `"${v.id}"`).join(', ')})), titleIgnorePrefix)`), 'display_title'])
bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.nameIgnorePrefix FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), titleIgnorePrefix)`), 'display_title'])
} else {
bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map(v => `"${v.id}"`).join(', ')})), \`book\`.\`title\`)`), 'display_title'])
bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), \`book\`.\`title\`)`), 'display_title'])
}
}
@ -598,15 +587,16 @@ module.exports = {
// For showing details of collapsed series
if (collapseseries && book.bookSeries?.length) {
const collapsedSeries = book.bookSeries.find(bs => collapseSeriesBookSeries.some(cbs => cbs.id === bs.id))
const collapsedSeries = book.bookSeries.find((bs) => collapseSeriesBookSeries.some((cbs) => cbs.id === bs.id))
if (collapsedSeries) {
const collapseSeriesObj = collapseSeriesBookSeries.find(csbs => csbs.id === collapsedSeries.id)
const collapseSeriesObj = collapseSeriesBookSeries.find((csbs) => csbs.id === collapsedSeries.id)
libraryItem.collapsedSeries = {
id: collapsedSeries.series.id,
name: collapsedSeries.series.name,
nameIgnorePrefix: collapsedSeries.series.nameIgnorePrefix,
sequence: collapsedSeries.sequence,
numBooks: collapseSeriesObj?.numBooks || 0
numBooks: collapseSeriesObj?.numBooks || 0,
libraryItemIds: collapseSeriesObj?.libraryItemIds || []
}
}
}
@ -633,11 +623,11 @@ module.exports = {
* 2. Has no books in progress
* 3. Has at least 1 unfinished book
* TODO: Reduce queries
* @param {import('../../objects/Library')} library
* @param {import('../../objects/user/User')} user
* @param {string[]} include
* @param {number} limit
* @param {number} offset
* @param {import('../../objects/Library')} library
* @param {import('../../objects/user/User')} user
* @param {string[]} include
* @param {number} limit
* @param {number} offset
* @returns {{ libraryItems:import('../../models/LibraryItem')[], count:number }}
*/
async getContinueSeriesLibraryItems(library, user, include, limit, offset) {
@ -655,15 +645,13 @@ module.exports = {
const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user)
bookWhere.push(...userPermissionBookWhere.bookWhere)
let includeAttributes = [
[Sequelize.literal('(SELECT max(mp.updatedAt) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.userId = :userId AND bs.seriesId = series.id)'), 'recent_progress'],
]
let includeAttributes = [[Sequelize.literal('(SELECT max(mp.updatedAt) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.userId = :userId AND bs.seriesId = series.id)'), 'recent_progress']]
let booksNotFinishedQuery = `SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId AND mp.userId = :userId WHERE bs.seriesId = series.id AND (mp.isFinished = 0 OR mp.isFinished IS NULL)`
if (library.settings.onlyShowLaterBooksInContinueSeries) {
const maxSequenceQuery = `(SELECT CAST(max(bs.sequence) as FLOAT) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.isFinished = 1 AND mp.userId = :userId AND bs.seriesId = series.id)`
includeAttributes.push([Sequelize.literal(`${maxSequenceQuery}`), 'maxSequence'])
booksNotFinishedQuery = booksNotFinishedQuery + ` AND CAST(bs.sequence as FLOAT) > ${maxSequenceQuery}`
}
@ -699,9 +687,7 @@ module.exports = {
attributes: ['bookId', 'sequence'],
separate: true,
subQuery: false,
order: [
[Sequelize.literal('CAST(sequence AS FLOAT) ASC NULLS LAST')]
],
order: [[Sequelize.literal('CAST(sequence AS FLOAT) ASC NULLS LAST')]],
where: {
'$book.mediaProgresses.isFinished$': {
[Sequelize.Op.or]: [null, 0]
@ -731,44 +717,44 @@ module.exports = {
]
}
},
order: [
[Sequelize.literal('recent_progress DESC')]
],
order: [[Sequelize.literal('recent_progress DESC')]],
distinct: true,
subQuery: false,
limit,
offset
})
const libraryItems = series.map(s => {
if (!s.bookSeries.length) return null // this is only possible if user has restricted books in series
const libraryItems = series
.map((s) => {
if (!s.bookSeries.length) return null // this is only possible if user has restricted books in series
let bookIndex = 0
// if the library setting is toggled, only show later entries in series, otherwise skip
if (library.settings.onlyShowLaterBooksInContinueSeries) {
bookIndex = s.bookSeries.findIndex(function (b) {
return parseFloat(b.dataValues.sequence) > s.dataValues.maxSequence
})
if (bookIndex === -1) {
// no later books than maxSequence
return null
let bookIndex = 0
// if the library setting is toggled, only show later entries in series, otherwise skip
if (library.settings.onlyShowLaterBooksInContinueSeries) {
bookIndex = s.bookSeries.findIndex(function (b) {
return parseFloat(b.dataValues.sequence) > s.dataValues.maxSequence
})
if (bookIndex === -1) {
// no later books than maxSequence
return null
}
}
}
const libraryItem = s.bookSeries[bookIndex].book.libraryItem.toJSON()
const book = s.bookSeries[bookIndex].book.toJSON()
delete book.libraryItem
libraryItem.series = {
id: s.id,
name: s.name,
sequence: s.bookSeries[bookIndex].sequence
}
if (libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0]
}
libraryItem.media = book
return libraryItem
}).filter(s => s)
const libraryItem = s.bookSeries[bookIndex].book.libraryItem.toJSON()
const book = s.bookSeries[bookIndex].book.toJSON()
delete book.libraryItem
libraryItem.series = {
id: s.id,
name: s.name,
sequence: s.bookSeries[bookIndex].sequence
}
if (libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0]
}
libraryItem.media = book
return libraryItem
})
.filter((s) => s)
return {
libraryItems,
@ -780,10 +766,10 @@ module.exports = {
* Get book library items for the "Discover" shelf
* Random selection of books that are not started
* - only includes the first book of a not-started series
* @param {string} libraryId
* @param {oldUser} user
* @param {string[]} include
* @param {number} limit
* @param {string} libraryId
* @param {oldUser} user
* @param {string[]} include
* @param {number} limit
* @returns {object} {libraryItems:LibraryItem, count:number}
*/
async getDiscoverLibraryItems(libraryId, user, include, limit) {
@ -811,9 +797,7 @@ module.exports = {
model: Database.bookModel,
where: userPermissionBookWhere.bookWhere
},
order: [
[Sequelize.literal('CAST(sequence AS FLOAT) ASC NULLS LAST')]
],
order: [[Sequelize.literal('CAST(sequence AS FLOAT) ASC NULLS LAST')]],
limit: 1
},
subQuery: false,
@ -821,7 +805,7 @@ module.exports = {
order: Database.sequelize.random()
})
const booksFromSeriesToInclude = seriesNotStarted.map(se => se.bookSeries?.[0]?.bookId).filter(bid => bid)
const booksFromSeriesToInclude = seriesNotStarted.map((se) => se.bookSeries?.[0]?.bookId).filter((bid) => bid)
// optional include rssFeed
const libraryItemIncludes = []
@ -913,7 +897,7 @@ module.exports = {
/**
* Get book library items in a collection
* @param {oldCollection} collection
* @param {oldCollection} collection
* @returns {Promise<LibraryItem[]>}
*/
async getLibraryItemsForCollection(collection) {
@ -957,22 +941,22 @@ module.exports = {
/**
* Get library items for series
* @param {import('../../objects/entities/Series')} oldSeries
* @param {import('../../objects/user/User')} [oldUser]
* @param {import('../../objects/entities/Series')} oldSeries
* @param {import('../../objects/user/User')} [oldUser]
* @returns {Promise<import('../../objects/LibraryItem')[]>}
*/
async getLibraryItemsForSeries(oldSeries, oldUser) {
const { libraryItems } = await this.getFilteredLibraryItems(oldSeries.libraryId, oldUser, 'series', oldSeries.id, null, null, false, [], null, null)
return libraryItems.map(li => Database.libraryItemModel.getOldLibraryItem(li))
return libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li))
},
/**
* Search books, authors, series
* @param {import('../../objects/user/User')} oldUser
* @param {import('../../objects/Library')} oldLibrary
* @param {string} query
* @param {number} limit
* @param {number} offset
* @param {import('../../objects/Library')} oldLibrary
* @param {string} query
* @param {number} limit
* @param {number} offset
* @returns {{book:object[], narrators:object[], authors:object[], tags:object[], series:object[]}}
*/
async search(oldUser, oldLibrary, query, limit, offset) {
@ -1151,7 +1135,7 @@ module.exports = {
/**
* Genres with num books
* @param {string} libraryId
* @param {string} libraryId
* @returns {{genre:string, count:number}[]}
*/
async getGenresWithCount(libraryId) {
@ -1173,7 +1157,7 @@ module.exports = {
/**
* Get stats for book library
* @param {string} libraryId
* @param {string} libraryId
* @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>}
*/
async getBookLibraryStats(libraryId) {
@ -1187,8 +1171,8 @@ module.exports = {
/**
* Get longest books in library
* @param {string} libraryId
* @param {number} limit
* @param {string} libraryId
* @param {number} limit
* @returns {Promise<{ id:string, title:string, duration:number }[]>}
*/
async getLongestBooks(libraryId, limit) {
@ -1201,12 +1185,10 @@ module.exports = {
libraryId
}
},
order: [
['duration', 'DESC']
],
order: [['duration', 'DESC']],
limit
})
return books.map(book => {
return books.map((book) => {
return {
id: book.libraryItem.id,
title: book.title,
@ -1214,4 +1196,4 @@ module.exports = {
}
})
}
}
}