mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +00:00
feat(client): add placeholder UX for series/upload flows, card styling/action guards, and localized strings
This commit is contained in:
parent
2a775d434a
commit
fc325a4095
23 changed files with 850 additions and 65 deletions
|
|
@ -134,6 +134,13 @@ export default {
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (this.userCanUpdate && this.isBookLibrary) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.ButtonAddPlaceholder,
|
||||||
|
action: 'add-placeholder'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (this.userIsAdminOrUp || this.selectedSeries.rssFeed) {
|
if (this.userIsAdminOrUp || this.selectedSeries.rssFeed) {
|
||||||
items.push({
|
items.push({
|
||||||
text: this.$strings.LabelOpenRSSFeed,
|
text: this.$strings.LabelOpenRSSFeed,
|
||||||
|
|
@ -427,6 +434,12 @@ export default {
|
||||||
seriesContextMenuAction({ action }) {
|
seriesContextMenuAction({ action }) {
|
||||||
if (action === 'open-rss-feed') {
|
if (action === 'open-rss-feed') {
|
||||||
this.showOpenSeriesRSSFeed()
|
this.showOpenSeriesRSSFeed()
|
||||||
|
} else if (action === 'add-placeholder') {
|
||||||
|
if (this.processingSeries) {
|
||||||
|
console.warn('Already processing series')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.createSeriesPlaceholder()
|
||||||
} else if (action === 're-add-to-continue-listening') {
|
} else if (action === 're-add-to-continue-listening') {
|
||||||
if (this.processingSeries) {
|
if (this.processingSeries) {
|
||||||
console.warn('Already processing series')
|
console.warn('Already processing series')
|
||||||
|
|
@ -566,6 +579,25 @@ export default {
|
||||||
}
|
}
|
||||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||||
},
|
},
|
||||||
|
async createSeriesPlaceholder() {
|
||||||
|
if (!this.seriesId) return
|
||||||
|
this.processingSeries = true
|
||||||
|
const payload = {
|
||||||
|
title: this.$strings.LabelPlaceholderDefaultTitle
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const libraryItem = await this.$axios.$post(`/api/libraries/${this.currentLibraryId}/series/${this.seriesId}/placeholders`, payload)
|
||||||
|
this.$toast.success(this.$strings.ToastPlaceholderCreated)
|
||||||
|
this.$store.commit('setBookshelfBookIds', [])
|
||||||
|
this.$store.commit('showEditModal', libraryItem)
|
||||||
|
this.$eventBus.$emit('series-bookshelf-refresh', { seriesId: this.seriesId })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create placeholder', error)
|
||||||
|
this.$toast.error(this.$strings.ToastPlaceholderCreateFailed)
|
||||||
|
} finally {
|
||||||
|
this.processingSeries = false
|
||||||
|
}
|
||||||
|
},
|
||||||
updateOrder() {
|
updateOrder() {
|
||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -566,8 +566,19 @@ export default {
|
||||||
this.entityComponentRefs[indexOf].setEntity(libraryItem)
|
this.entityComponentRefs[indexOf].setEntity(libraryItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (this.entityName === 'series-books' && this.seriesId) {
|
||||||
|
const seriesIds = libraryItem.media?.metadata?.series?.map((se) => se.id) || []
|
||||||
|
if (!seriesIds.includes(this.seriesId)) {
|
||||||
|
this.resetEntities(this.currScrollTop)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
seriesBookshelfRefresh({ seriesId } = {}) {
|
||||||
|
if (this.entityName !== 'series-books') return
|
||||||
|
if (seriesId && this.seriesId && seriesId !== this.seriesId) return
|
||||||
|
this.resetEntities(this.currScrollTop)
|
||||||
|
},
|
||||||
routeToBookshelfIfLastIssueRemoved() {
|
routeToBookshelfIfLastIssueRemoved() {
|
||||||
if (this.totalEntities === 0) {
|
if (this.totalEntities === 0) {
|
||||||
const currentRouteQuery = this.$route.query
|
const currentRouteQuery = this.$route.query
|
||||||
|
|
@ -791,6 +802,7 @@ export default {
|
||||||
|
|
||||||
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||||
|
this.$eventBus.$on('series-bookshelf-refresh', this.seriesBookshelfRefresh)
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
|
|
@ -822,6 +834,7 @@ export default {
|
||||||
|
|
||||||
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
|
this.$eventBus.$off('series-bookshelf-refresh', this.seriesBookshelfRefresh)
|
||||||
|
|
||||||
if (this.$root.socket) {
|
if (this.$root.socket) {
|
||||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,10 @@
|
||||||
<ui-text-input-with-label v-model.trim="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" inputClass="h-10" />
|
<ui-text-input-with-label v-model.trim="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" inputClass="h-10" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
<div class="w-full">
|
<div v-if="showPlaceholderSelector" class="w-full" cy-id="directoryPlaceholderSelector">
|
||||||
|
<ui-select-input :value="placeholderSelectionValue" :disabled="processing" :label="$strings.LabelDirectoryPlaceholder" :items="directoryPlaceholderItems" @input="onPlaceholderSelectionInput" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-full">
|
||||||
<label class="px-1 text-sm font-semibold">
|
<label class="px-1 text-sm font-semibold">
|
||||||
{{ $strings.LabelDirectory }}
|
{{ $strings.LabelDirectory }}
|
||||||
<em class="font-normal text-xs pl-2">(auto)</em>
|
<em class="font-normal text-xs pl-2">(auto)</em>
|
||||||
|
|
@ -95,7 +98,16 @@ export default {
|
||||||
},
|
},
|
||||||
mediaType: String,
|
mediaType: String,
|
||||||
processing: Boolean,
|
processing: Boolean,
|
||||||
provider: String
|
provider: String,
|
||||||
|
showPlaceholderSelector: Boolean,
|
||||||
|
placeholderTarget: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
placeholderItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -128,6 +140,27 @@ export default {
|
||||||
|
|
||||||
return Path.join(...cleanedOutputPathParts)
|
return Path.join(...cleanedOutputPathParts)
|
||||||
},
|
},
|
||||||
|
placeholderSelectionValue() {
|
||||||
|
return this.placeholderTarget || ''
|
||||||
|
},
|
||||||
|
directoryPlaceholderItems() {
|
||||||
|
const placeholderOptions = this.placeholderItems.map((placeholder) => {
|
||||||
|
const title = placeholder?.media?.metadata?.title || placeholder.title || placeholder.path || placeholder.id
|
||||||
|
const author = placeholder?.media?.metadata?.authorName || placeholder.authorNamesFirstLast || ''
|
||||||
|
return {
|
||||||
|
value: `id:${placeholder.id}`,
|
||||||
|
text: author ? `${title} (${author})` : title
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
value: '',
|
||||||
|
text: this.directory
|
||||||
|
},
|
||||||
|
...placeholderOptions
|
||||||
|
]
|
||||||
|
},
|
||||||
isNonInteractable() {
|
isNonInteractable() {
|
||||||
return this.isUploading || this.isFetchingMetadata
|
return this.isUploading || this.isFetchingMetadata
|
||||||
},
|
},
|
||||||
|
|
@ -172,6 +205,9 @@ export default {
|
||||||
titleUpdated() {
|
titleUpdated() {
|
||||||
this.error = ''
|
this.error = ''
|
||||||
},
|
},
|
||||||
|
onPlaceholderSelectionInput(value) {
|
||||||
|
this.$emit('placeholder-target-updated', value || '')
|
||||||
|
},
|
||||||
async fetchMetadata() {
|
async fetchMetadata() {
|
||||||
if (!this.itemData.title.trim().length) {
|
if (!this.itemData.title.trim().length) {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="card" :id="`book-card-${index}`" tabindex="0" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-xs z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
<div ref="card" :id="`book-card-${index}`" tabindex="0" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-xs z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||||
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded-sm overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
|
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded-sm overflow-hidden z-10 bg-primary box-shadow-book" :class="{ 'placeholder-card': isPlaceholder }" :style="{ height: coverHeight + 'px ' }">
|
||||||
<!-- When cover image does not fill -->
|
<!-- When cover image does not fill -->
|
||||||
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-xs bg-primary">
|
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-xs bg-primary">
|
||||||
<div class="absolute cover-bg" ref="coverBg" />
|
<div class="absolute cover-bg" ref="coverBg" />
|
||||||
|
|
@ -12,8 +12,13 @@
|
||||||
<div cy-id="booksInSeries" v-else-if="booksInSeries" class="absolute rounded-lg bg-black/90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
<div cy-id="booksInSeries" v-else-if="booksInSeries" class="absolute rounded-lg bg-black/90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ booksInSeries }}</p>
|
<p :style="{ fontSize: 0.8 + 'em' }">{{ booksInSeries }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isPlaceholder" cy-id="placeholderPremiereBorderOuter" aria-hidden="true" class="placeholder-premiere-border-outer" />
|
||||||
|
<div v-if="isPlaceholder" cy-id="placeholderPremiereBorderInner" aria-hidden="true" class="placeholder-premiere-border-inner" />
|
||||||
|
|
||||||
<div class="w-full h-full absolute top-0 left-0 rounded-sm overflow-hidden z-10">
|
<div class="w-full h-full absolute top-0 left-0 rounded-sm overflow-hidden z-10">
|
||||||
|
<div v-if="isPlaceholder" cy-id="placeholderPremiereRibbon" aria-hidden="true" class="placeholder-premiere-ribbon-wrapper">
|
||||||
|
<div class="placeholder-premiere-ribbon-text">{{ $strings.LabelPlaceholderComingSoon }}</div>
|
||||||
|
</div>
|
||||||
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" aria-hidden="true" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
|
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" aria-hidden="true" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
|
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -22,17 +27,17 @@
|
||||||
<img cy-id="coverImage" v-if="libraryItem" :alt="`${displayTitle}, ${$strings.LabelCover}`" ref="cover" aria-hidden="true" :src="bookCoverSrc" class="relative 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-if="libraryItem" :alt="`${displayTitle}, ${$strings.LabelCover}`" ref="cover" aria-hidden="true" :src="bookCoverSrc" class="relative 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 -->
|
<!-- Placeholder Cover Title & Author -->
|
||||||
<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 + 'em' }">
|
<div cy-id="placeholderTitle" v-if="showPlaceholderDetails" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
|
||||||
<div>
|
<div>
|
||||||
<p cy-id="placeholderTitleText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
<p cy-id="placeholderTitleText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
|
<div cy-id="placeholderAuthor" v-if="showPlaceholderDetails" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
|
||||||
<p cy-id="placeholderAuthorText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
<p cy-id="placeholderAuthorText" aria-hidden="true" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- No progress shown for podcasts (unless showing podcast episode) -->
|
<!-- No progress shown for podcasts (unless showing podcast episode) -->
|
||||||
<div cy-id="progressBar" v-if="!isPodcast || episodeProgress" class="absolute bottom-0 left-0 h-1e max-w-full z-20 rounded-b box-shadow-progressbar" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
<div cy-id="progressBar" v-if="(!isPodcast || episodeProgress) && !isPlaceholder" class="absolute bottom-0 left-0 h-1e max-w-full z-20 rounded-b box-shadow-progressbar" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
<!-- Overlay is not shown if collapsing series in library -->
|
<!-- Overlay is not shown if collapsing series in library -->
|
||||||
<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-sm md:block" :class="overlayWrapperClasslist">
|
<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-sm md:block" :class="overlayWrapperClasslist">
|
||||||
|
|
@ -291,9 +296,15 @@ export default {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this.collapsedSeries?.libraryItemIds || []
|
return this.collapsedSeries?.libraryItemIds || []
|
||||||
},
|
},
|
||||||
|
isPlaceholder() {
|
||||||
|
return !!this._libraryItem.isPlaceholder
|
||||||
|
},
|
||||||
hasCover() {
|
hasCover() {
|
||||||
return !!this.media.coverPath
|
return !!this.media.coverPath
|
||||||
},
|
},
|
||||||
|
showPlaceholderDetails() {
|
||||||
|
return this.bookCoverSrc === this.placeholderUrl
|
||||||
|
},
|
||||||
squareAspectRatio() {
|
squareAspectRatio() {
|
||||||
return this.bookCoverAspectRatio === 1
|
return this.bookCoverAspectRatio === 1
|
||||||
},
|
},
|
||||||
|
|
@ -436,7 +447,7 @@ export default {
|
||||||
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
|
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
return !this.isPlaceholder && !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||||
},
|
},
|
||||||
showSmallEBookIcon() {
|
showSmallEBookIcon() {
|
||||||
return !this.isSelectionMode && this.ebookFormat
|
return !this.isSelectionMode && this.ebookFormat
|
||||||
|
|
@ -584,7 +595,7 @@ export default {
|
||||||
text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening
|
text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!this.isPodcast) {
|
if (!this.isPodcast && !this.isPlaceholder) {
|
||||||
if (this.libraryItemIdStreaming && !this.isStreamingFromDifferentLibrary) {
|
if (this.libraryItemIdStreaming && !this.isStreamingFromDifferentLibrary) {
|
||||||
if (!this.isQueued) {
|
if (!this.isQueued) {
|
||||||
items.push({
|
items.push({
|
||||||
|
|
@ -1105,3 +1116,56 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.placeholder-card {
|
||||||
|
filter: grayscale(0.7);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-premiere-border-outer,
|
||||||
|
.placeholder-premiere-border-inner {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-premiere-border-outer {
|
||||||
|
inset: 3px;
|
||||||
|
border: 1px solid rgba(214, 218, 224, 0.5);
|
||||||
|
box-shadow: inset 0 0 8px rgba(255, 255, 255, 0.08);
|
||||||
|
z-index: 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-premiere-border-inner {
|
||||||
|
inset: 6px;
|
||||||
|
border: 1px solid rgba(238, 242, 249, 0.35);
|
||||||
|
box-shadow: inset 0 0 6px rgba(255, 255, 255, 0.12);
|
||||||
|
z-index: 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-premiere-ribbon-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 52%;
|
||||||
|
left: 50%;
|
||||||
|
width: 150%;
|
||||||
|
transform: translate(-50%, -50%) rotate(-32deg);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 19;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-premiere-ribbon-text {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.72em;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: rgba(250, 252, 255, 0.9);
|
||||||
|
text-shadow: 0 0 2px rgba(221, 226, 238, 0.35), 0 1px 1px rgba(16, 20, 28, 0.45);
|
||||||
|
background: linear-gradient(90deg, rgba(121, 129, 145, 0.3) 0%, rgba(206, 213, 223, 0.55) 45%, rgba(244, 247, 255, 0.3) 50%, rgba(196, 201, 212, 0.5) 55%, rgba(108, 116, 132, 0.28) 100%);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
border-bottom: 1px solid rgba(85, 94, 110, 0.4);
|
||||||
|
backdrop-filter: blur(0.5px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black/90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black/90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
||||||
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfBooks">{{ books.length }}</p>
|
<p :style="{ fontSize: 0.8 + 'em' }" role="status" :aria-label="$strings.LabelNumberOfBooks">{{ seriesBooksCount }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-xs max-w-full z-10 rounded-b w-full box-shadow-progressbar" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-xs max-w-full z-10 rounded-b w-full box-shadow-progressbar" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||||
|
|
@ -112,6 +112,10 @@ export default {
|
||||||
books() {
|
books() {
|
||||||
return this.series?.books || []
|
return this.series?.books || []
|
||||||
},
|
},
|
||||||
|
seriesBooksCount() {
|
||||||
|
if (typeof this.series?.numBooks === 'number') return this.series.numBooks
|
||||||
|
return this.books.filter((book) => !book.isPlaceholder).length
|
||||||
|
},
|
||||||
addedAt() {
|
addedAt() {
|
||||||
return this.series?.addedAt || 0
|
return this.series?.addedAt || 0
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
import BookShelfToolbar from '@/components/app/BookShelfToolbar.vue'
|
||||||
|
import ContextMenuDropdown from '@/components/ui/ContextMenuDropdown.vue'
|
||||||
|
|
||||||
|
describe('BookShelfToolbar placeholder flow', () => {
|
||||||
|
const libraryItem = {
|
||||||
|
id: 'item-1',
|
||||||
|
libraryId: 'library-123',
|
||||||
|
mediaType: 'book',
|
||||||
|
isPlaceholder: true,
|
||||||
|
media: {
|
||||||
|
metadata: {
|
||||||
|
title: 'Placeholder Title',
|
||||||
|
authorName: 'Placeholder Author'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseSelectedSeries = {
|
||||||
|
id: 'series-1',
|
||||||
|
name: 'Series Name',
|
||||||
|
progress: {
|
||||||
|
libraryItemIds: ['item-1'],
|
||||||
|
isFinished: false
|
||||||
|
},
|
||||||
|
rssFeed: null
|
||||||
|
}
|
||||||
|
|
||||||
|
const stubs = {
|
||||||
|
'ui-context-menu-dropdown': ContextMenuDropdown
|
||||||
|
}
|
||||||
|
|
||||||
|
const createMountOptions = (overrides = {}) => {
|
||||||
|
const mocks = {
|
||||||
|
$strings: {
|
||||||
|
ButtonAddPlaceholder: 'Add Placeholder',
|
||||||
|
ToastPlaceholderCreated: 'Placeholder created',
|
||||||
|
ToastPlaceholderCreateFailed: 'Placeholder create failed',
|
||||||
|
LabelPlaceholderDefaultTitle: 'Placeholder',
|
||||||
|
LabelOpenRSSFeed: 'Open RSS Feed',
|
||||||
|
MessageMarkAsNotFinished: 'Mark as not finished',
|
||||||
|
MessageMarkAsFinished: 'Mark as finished',
|
||||||
|
LabelReAddSeriesToContinueListening: 'Re-add to continue listening',
|
||||||
|
LabelShowSubtitles: 'Show subtitles',
|
||||||
|
LabelHideSubtitles: 'Hide subtitles',
|
||||||
|
LabelExpandSubSeries: 'Expand sub-series',
|
||||||
|
LabelCollapseSubSeries: 'Collapse sub-series',
|
||||||
|
LabelExpandSeries: 'Expand series',
|
||||||
|
LabelCollapseSeries: 'Collapse series',
|
||||||
|
ButtonLibrary: 'Library',
|
||||||
|
ButtonHome: 'Home',
|
||||||
|
ButtonSeries: 'Series',
|
||||||
|
ButtonPlaylists: 'Playlists',
|
||||||
|
ButtonCollections: 'Collections',
|
||||||
|
ButtonAuthors: 'Authors',
|
||||||
|
ButtonAdd: 'Add',
|
||||||
|
ButtonLatest: 'Latest',
|
||||||
|
ButtonDownloadQueue: 'Download Queue',
|
||||||
|
LabelPodcasts: 'Podcasts',
|
||||||
|
LabelBooks: 'Books',
|
||||||
|
LabelSeries: 'Series',
|
||||||
|
LabelCollections: 'Collections',
|
||||||
|
LabelPlaylists: 'Playlists',
|
||||||
|
LabelAuthors: 'Authors'
|
||||||
|
},
|
||||||
|
$axios: {
|
||||||
|
$post: cy.stub().resolves(libraryItem)
|
||||||
|
},
|
||||||
|
$toast: {
|
||||||
|
success: cy.stub().as('toastSuccess'),
|
||||||
|
error: cy.stub().as('toastError')
|
||||||
|
},
|
||||||
|
$router: {
|
||||||
|
push: cy.stub().as('routerPush')
|
||||||
|
},
|
||||||
|
$eventBus: {
|
||||||
|
$emit: cy.stub().as('eventEmit')
|
||||||
|
},
|
||||||
|
$store: {
|
||||||
|
commit: cy.stub().as('storeCommit'),
|
||||||
|
dispatch: cy.stub().as('storeDispatch'),
|
||||||
|
getters: {
|
||||||
|
'user/getIsAdminOrUp': true,
|
||||||
|
'user/getUserCanDelete': true,
|
||||||
|
'user/getUserCanUpdate': true,
|
||||||
|
'user/getUserCanDownload': true,
|
||||||
|
'globals/getIsBatchSelectingMediaItems': false,
|
||||||
|
'libraries/getLibraryProvider': () => 'audible.us',
|
||||||
|
'libraries/getCurrentLibraryMediaType': 'book',
|
||||||
|
'user/getUserSetting': () => 'all',
|
||||||
|
'user/getIsSeriesRemovedFromContinueListening': () => false
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
libraries: {
|
||||||
|
currentLibraryId: 'library-123',
|
||||||
|
numUserPlaylists: 0
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
settings: {
|
||||||
|
showSubtitles: false,
|
||||||
|
collapseSeries: false,
|
||||||
|
collapseBookSeries: false,
|
||||||
|
filterBy: 'all',
|
||||||
|
orderBy: 'addedAt',
|
||||||
|
orderDesc: false,
|
||||||
|
seriesFilterBy: 'all',
|
||||||
|
seriesSortBy: 'addedAt',
|
||||||
|
seriesSortDesc: false,
|
||||||
|
authorSortBy: 'name',
|
||||||
|
authorSortDesc: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
$route: {
|
||||||
|
name: 'library-library',
|
||||||
|
query: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
propsData: {
|
||||||
|
page: 'series',
|
||||||
|
isHome: false,
|
||||||
|
selectedSeries: baseSelectedSeries
|
||||||
|
},
|
||||||
|
stubs,
|
||||||
|
mocks,
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates a placeholder and refreshes the series shelf', () => {
|
||||||
|
const mountOptions = createMountOptions()
|
||||||
|
cy.mount(BookShelfToolbar, mountOptions)
|
||||||
|
|
||||||
|
cy.get('button[aria-haspopup="menu"]').click()
|
||||||
|
cy.contains('button[role="menuitem"]', 'Add Placeholder').click()
|
||||||
|
|
||||||
|
cy.get('@toastSuccess').should('have.been.calledOnce')
|
||||||
|
cy.get('@storeCommit').should('have.been.calledWith', 'setBookshelfBookIds', [])
|
||||||
|
cy.get('@storeCommit').should('have.been.calledWith', 'showEditModal', libraryItem)
|
||||||
|
cy.get('@eventEmit').should('have.been.calledWith', 'series-bookshelf-refresh', { seriesId: baseSelectedSeries.id })
|
||||||
|
cy.wrap(mountOptions.mocks.$axios.$post).should('have.been.calledWith', '/api/libraries/library-123/series/series-1/placeholders', {
|
||||||
|
title: 'Placeholder'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -36,6 +36,9 @@ function createMountOptions() {
|
||||||
$config: {
|
$config: {
|
||||||
routerBasePath: 'https://my.server.com'
|
routerBasePath: 'https://my.server.com'
|
||||||
},
|
},
|
||||||
|
$strings: {
|
||||||
|
LabelPlaceholderComingSoon: 'Localized Coming Soon'
|
||||||
|
},
|
||||||
$store: {
|
$store: {
|
||||||
commit: () => {},
|
commit: () => {},
|
||||||
getters: {
|
getters: {
|
||||||
|
|
@ -163,6 +166,21 @@ describe('LazyBookCard', () => {
|
||||||
cy.get('&ebookFormat').should('not.exist')
|
cy.get('&ebookFormat').should('not.exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders placeholder styling and hides play button for placeholders', () => {
|
||||||
|
mountOptions.propsData.bookMount.isPlaceholder = true
|
||||||
|
cy.mount(LazyBookCard, mountOptions)
|
||||||
|
|
||||||
|
cy.get('&placeholderBadge').should('not.exist')
|
||||||
|
cy.get('#cover-area-0').should('have.class', 'placeholder-card')
|
||||||
|
cy.get('&placeholderPremiereRibbon').should('be.visible').and('contain.text', mountOptions.mocks.$strings.LabelPlaceholderComingSoon)
|
||||||
|
cy.get('&placeholderPremiereBorderOuter').should('be.visible')
|
||||||
|
cy.get('&placeholderPremiereBorderInner').should('be.visible')
|
||||||
|
|
||||||
|
cy.get('#book-card-0').trigger('mouseover')
|
||||||
|
cy.get('&playButton').should('be.hidden')
|
||||||
|
cy.get('&editButton').should('be.visible')
|
||||||
|
})
|
||||||
|
|
||||||
it('routes to item page when clicked', () => {
|
it('routes to item page when clicked', () => {
|
||||||
mountOptions.mocks.$router = { push: cy.stub().as('routerPush') }
|
mountOptions.mocks.$router = { push: cy.stub().as('routerPush') }
|
||||||
cy.mount(LazyBookCard, mountOptions)
|
cy.mount(LazyBookCard, mountOptions)
|
||||||
|
|
@ -211,6 +229,16 @@ describe('LazyBookCard', () => {
|
||||||
cy.get('&placeholderAuthor').should('not.exist')
|
cy.get('&placeholderAuthor').should('not.exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows placeholder title and author when cover source is the placeholder image', () => {
|
||||||
|
mountOptions.mocks.$store.getters['globals/getLibraryItemCoverSrc'] = () => 'https://my.server.com/book_placeholder.jpg'
|
||||||
|
mountOptions.propsData.bookMount.media.coverPath = 'book_placeholder.jpg'
|
||||||
|
cy.mount(LazyBookCard, mountOptions)
|
||||||
|
|
||||||
|
cy.get('&titleImageNotReady').should('be.hidden')
|
||||||
|
cy.get('&placeholderTitle').should('be.visible')
|
||||||
|
cy.get('&placeholderAuthor').should('be.visible')
|
||||||
|
})
|
||||||
|
|
||||||
it('hides detailBottom when bookShelfView is STANDARD', () => {
|
it('hides detailBottom when bookShelfView is STANDARD', () => {
|
||||||
mountOptions.propsData.bookshelfView = Constants.BookshelfView.STANDARD
|
mountOptions.propsData.bookshelfView = Constants.BookshelfView.STANDARD
|
||||||
cy.mount(LazyBookCard, mountOptions)
|
cy.mount(LazyBookCard, mountOptions)
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,19 @@ describe('LazySeriesCard', () => {
|
||||||
cy.get('&detailBottomSortLine').should('have.text', 'Added 04/17/2024')
|
cy.get('&detailBottomSortLine').should('have.text', 'Added 04/17/2024')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('excludes placeholder entries from series length marker count', () => {
|
||||||
|
const updatedPropsData = {
|
||||||
|
...propsData,
|
||||||
|
seriesMount: {
|
||||||
|
...series,
|
||||||
|
books: [...series.books, { id: 4, isPlaceholder: true, media: { coverPath: 'book_placeholder.jpg' }, title: 'Upcoming Book' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cy.mount(LazySeriesCard, { propsData: updatedPropsData, stubs, mocks })
|
||||||
|
|
||||||
|
cy.get('&seriesLengthMarker').should('be.visible').and('have.text', '3')
|
||||||
|
})
|
||||||
|
|
||||||
it('shows series name and hides rss feed marker on mouseover', () => {
|
it('shows series name and hides rss feed marker on mouseover', () => {
|
||||||
cy.mount(LazySeriesCard, { propsData, stubs, mocks })
|
cy.mount(LazySeriesCard, { propsData, stubs, mocks })
|
||||||
cy.get('&card').trigger('mouseover')
|
cy.get('&card').trigger('mouseover')
|
||||||
|
|
|
||||||
122
client/cypress/tests/components/pages/Upload.placeholder.cy.js
Normal file
122
client/cypress/tests/components/pages/Upload.placeholder.cy.js
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import UploadPage from '@/pages/upload/index.vue'
|
||||||
|
|
||||||
|
describe('Upload page placeholder targeting', () => {
|
||||||
|
it('renders directory/placeholder selector in item card without none option and sends folder-specific placeholder payload', () => {
|
||||||
|
const uploadFile = new File(['audio'], 'track-01.mp3', { type: 'audio/mpeg' })
|
||||||
|
const library = {
|
||||||
|
id: 'library-1',
|
||||||
|
name: 'Test Library',
|
||||||
|
mediaType: 'book',
|
||||||
|
folders: [
|
||||||
|
{
|
||||||
|
id: 'folder-1',
|
||||||
|
fullPath: '/library',
|
||||||
|
path: '/library'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'folder-2',
|
||||||
|
fullPath: '/library-2',
|
||||||
|
path: '/library-2'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholderItemsPayload = {
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
id: 'placeholder-1',
|
||||||
|
isPlaceholder: true,
|
||||||
|
folderId: 'folder-1',
|
||||||
|
media: { metadata: { title: 'Placeholder One', authorName: 'Author One' } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'placeholder-2',
|
||||||
|
isPlaceholder: true,
|
||||||
|
folderId: 'folder-2',
|
||||||
|
media: { metadata: { title: 'Placeholder Two', authorName: 'Author Two' } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const postStub = cy.stub().callsFake((url, payload) => {
|
||||||
|
if (url === '/api/upload') {
|
||||||
|
expect(payload.get('folder')).to.equal('folder-2')
|
||||||
|
expect(payload.get('placeholder')).to.equal('id:placeholder-2')
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === '/api/filesystem/pathexists') {
|
||||||
|
throw new Error('Path checks should be skipped for placeholder uploads')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({ exists: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
const mountOptions = {
|
||||||
|
mocks: {
|
||||||
|
$axios: {
|
||||||
|
$get: cy.stub().callsFake((url) => {
|
||||||
|
if (url.includes('/api/libraries/library-1/items') && url.includes('include=placeholders')) {
|
||||||
|
return Promise.resolve(placeholderItemsPayload)
|
||||||
|
}
|
||||||
|
return Promise.resolve({ results: [] })
|
||||||
|
}),
|
||||||
|
$post: postStub
|
||||||
|
},
|
||||||
|
$toast: {
|
||||||
|
error: cy.stub()
|
||||||
|
},
|
||||||
|
$store: {
|
||||||
|
dispatch: cy.stub().resolves(),
|
||||||
|
getters: {
|
||||||
|
'libraries/getLibraryProvider': () => 'audible.us'
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
streamLibraryItem: null,
|
||||||
|
scanners: {
|
||||||
|
podcastProviders: [],
|
||||||
|
bookProviders: []
|
||||||
|
},
|
||||||
|
libraries: {
|
||||||
|
currentLibraryId: library.id,
|
||||||
|
libraries: [library]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.mount(UploadPage, mountOptions)
|
||||||
|
|
||||||
|
cy.wrap(Cypress.vueWrapper.vm).then((vm) => {
|
||||||
|
vm.selectedFolderId = 'folder-2'
|
||||||
|
vm.placeholderTargetByFolderId = {
|
||||||
|
...vm.placeholderTargetByFolderId,
|
||||||
|
'folder-2': 'id:placeholder-2'
|
||||||
|
}
|
||||||
|
vm.items = [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
title: 'Placeholder',
|
||||||
|
author: 'Author Name',
|
||||||
|
series: 'Series Name',
|
||||||
|
itemFiles: [uploadFile],
|
||||||
|
otherFiles: [],
|
||||||
|
ignoredFiles: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return vm.$nextTick()
|
||||||
|
})
|
||||||
|
|
||||||
|
cy.contains('label', 'Directory/Placeholder').should('exist')
|
||||||
|
cy.get('&directoryPlaceholderSelector').should('have.length', 1)
|
||||||
|
cy.get('&directoryPlaceholderSelector').contains('Placeholder Two')
|
||||||
|
cy.get('&directoryPlaceholderSelector').contains('Author Two')
|
||||||
|
cy.get('&directoryPlaceholderSelector').should('not.contain', 'None (upload as new item)')
|
||||||
|
|
||||||
|
cy.contains('button', 'Upload').click()
|
||||||
|
|
||||||
|
cy.wrap(postStub).should('have.been.calledOnceWithMatch', '/api/upload')
|
||||||
|
cy.wrap(postStub).should('have.been.calledWithMatch', '/api/upload')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -74,7 +74,20 @@
|
||||||
</widgets-alert>
|
</widgets-alert>
|
||||||
|
|
||||||
<!-- Item Upload cards -->
|
<!-- Item Upload cards -->
|
||||||
<cards-item-upload-card v-for="item in items" :key="item.index" :ref="`itemCard-${item.index}`" :media-type="selectedLibraryMediaType" :item="item" :provider="fetchMetadata.provider" :processing="processing" @remove="removeItem(item)" />
|
<cards-item-upload-card
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.index"
|
||||||
|
:ref="`itemCard-${item.index}`"
|
||||||
|
:media-type="selectedLibraryMediaType"
|
||||||
|
:item="item"
|
||||||
|
:provider="fetchMetadata.provider"
|
||||||
|
:processing="processing"
|
||||||
|
:show-placeholder-selector="showPlaceholderTarget"
|
||||||
|
:placeholder-target="cleanedPlaceholderTarget"
|
||||||
|
:placeholder-items="placeholderItemsForSelectedFolder"
|
||||||
|
@remove="removeItem(item)"
|
||||||
|
@placeholder-target-updated="updatePlaceholderTargetForSelectedFolder"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Upload/Reset btns -->
|
<!-- Upload/Reset btns -->
|
||||||
<div v-show="items.length" class="flex justify-end pb-8 pt-4">
|
<div v-show="items.length" class="flex justify-end pb-8 pt-4">
|
||||||
|
|
@ -104,6 +117,8 @@ export default {
|
||||||
selectedFolderId: null,
|
selectedFolderId: null,
|
||||||
processing: false,
|
processing: false,
|
||||||
uploadFinished: false,
|
uploadFinished: false,
|
||||||
|
placeholderTargetByFolderId: {},
|
||||||
|
placeholdersByFolderId: {},
|
||||||
fetchMetadata: {
|
fetchMetadata: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
provider: null
|
provider: null
|
||||||
|
|
@ -175,10 +190,22 @@ export default {
|
||||||
},
|
},
|
||||||
uploadReady() {
|
uploadReady() {
|
||||||
return !this.items.length && !this.ignoredFiles.length && !this.uploadFinished
|
return !this.items.length && !this.ignoredFiles.length && !this.uploadFinished
|
||||||
|
},
|
||||||
|
showPlaceholderTarget() {
|
||||||
|
return !this.selectedLibraryIsPodcast && this.items.length > 0
|
||||||
|
},
|
||||||
|
placeholderItemsForSelectedFolder() {
|
||||||
|
if (!this.selectedFolderId) return []
|
||||||
|
return this.placeholdersByFolderId[this.selectedFolderId] || []
|
||||||
|
},
|
||||||
|
cleanedPlaceholderTarget() {
|
||||||
|
if (!this.selectedFolderId) return ''
|
||||||
|
const value = this.placeholderTargetByFolderId[this.selectedFolderId]
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
libraryChanged() {
|
async libraryChanged() {
|
||||||
if (!this.selectedLibrary && this.selectedFolderId) {
|
if (!this.selectedLibrary && this.selectedFolderId) {
|
||||||
this.selectedFolderId = null
|
this.selectedFolderId = null
|
||||||
} else if (this.selectedFolderId) {
|
} else if (this.selectedFolderId) {
|
||||||
|
|
@ -188,6 +215,7 @@ export default {
|
||||||
}
|
}
|
||||||
this.setDefaultFolder()
|
this.setDefaultFolder()
|
||||||
this.setMetadataProvider()
|
this.setMetadataProvider()
|
||||||
|
await this.fetchPlaceholdersForLibrary()
|
||||||
},
|
},
|
||||||
setDefaultFolder() {
|
setDefaultFolder() {
|
||||||
if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {
|
if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {
|
||||||
|
|
@ -208,9 +236,45 @@ export default {
|
||||||
this.items = []
|
this.items = []
|
||||||
this.ignoredFiles = []
|
this.ignoredFiles = []
|
||||||
this.uploadFinished = false
|
this.uploadFinished = false
|
||||||
|
this.placeholderTargetByFolderId = {}
|
||||||
if (this.$refs.fileInput) this.$refs.fileInput.value = ''
|
if (this.$refs.fileInput) this.$refs.fileInput.value = ''
|
||||||
if (this.$refs.fileFolderInput) this.$refs.fileFolderInput.value = ''
|
if (this.$refs.fileFolderInput) this.$refs.fileFolderInput.value = ''
|
||||||
},
|
},
|
||||||
|
async fetchPlaceholdersForLibrary() {
|
||||||
|
if (!this.selectedLibraryId || this.selectedLibraryIsPodcast) {
|
||||||
|
this.placeholdersByFolderId = {}
|
||||||
|
this.placeholderTargetByFolderId = {}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = await this.$axios
|
||||||
|
.$get(`/api/libraries/${this.selectedLibraryId}/items?include=placeholders&limit=0`)
|
||||||
|
.then((data) => data.results || [])
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to load placeholders', error)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const placeholdersByFolderId = {}
|
||||||
|
placeholders.forEach((item) => {
|
||||||
|
if (!item?.isPlaceholder || !item.folderId) return
|
||||||
|
if (!placeholdersByFolderId[item.folderId]) placeholdersByFolderId[item.folderId] = []
|
||||||
|
placeholdersByFolderId[item.folderId].push(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextPlaceholderTargetByFolderId = {}
|
||||||
|
const folders = this.selectedLibrary?.folders || []
|
||||||
|
folders.forEach((folder) => {
|
||||||
|
nextPlaceholderTargetByFolderId[folder.id] = this.placeholderTargetByFolderId[folder.id] || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
this.placeholdersByFolderId = placeholdersByFolderId
|
||||||
|
this.placeholderTargetByFolderId = nextPlaceholderTargetByFolderId
|
||||||
|
},
|
||||||
|
updatePlaceholderTargetForSelectedFolder(target) {
|
||||||
|
if (!this.selectedFolderId) return
|
||||||
|
this.$set(this.placeholderTargetByFolderId, this.selectedFolderId, target || '')
|
||||||
|
},
|
||||||
openFilePicker() {
|
openFilePicker() {
|
||||||
if (this.$refs.fileInput) this.$refs.fileInput.click()
|
if (this.$refs.fileInput) this.$refs.fileInput.click()
|
||||||
},
|
},
|
||||||
|
|
@ -315,6 +379,9 @@ export default {
|
||||||
}
|
}
|
||||||
form.set('library', this.selectedLibraryId)
|
form.set('library', this.selectedLibraryId)
|
||||||
form.set('folder', this.selectedFolderId)
|
form.set('folder', this.selectedFolderId)
|
||||||
|
if (this.cleanedPlaceholderTarget) {
|
||||||
|
form.set('placeholder', this.cleanedPlaceholderTarget)
|
||||||
|
}
|
||||||
|
|
||||||
var index = 0
|
var index = 0
|
||||||
item.files.forEach((file) => {
|
item.files.forEach((file) => {
|
||||||
|
|
@ -380,6 +447,11 @@ export default {
|
||||||
// Check if path already exists before starting upload
|
// Check if path already exists before starting upload
|
||||||
// uploading fails if path already exists
|
// uploading fails if path already exists
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
if (this.cleanedPlaceholderTarget) {
|
||||||
|
itemsToUpload.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const exists = await this.$axios
|
const exists = await this.$axios
|
||||||
.$post(`/api/filesystem/pathexists`, { directory: item.directory, folderPath: this.selectedFolder.fullPath })
|
.$post(`/api/filesystem/pathexists`, { directory: item.directory, folderPath: this.selectedFolder.fullPath })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
|
|
@ -415,6 +487,7 @@ export default {
|
||||||
this.setMetadataProvider()
|
this.setMetadataProvider()
|
||||||
|
|
||||||
this.setDefaultFolder()
|
this.setDefaultFolder()
|
||||||
|
this.fetchPlaceholdersForLibrary()
|
||||||
// Fetch providers if not already loaded
|
// Fetch providers if not already loaded
|
||||||
this.$store.dispatch('scanners/fetchProviders')
|
this.$store.dispatch('scanners/fetchProviders')
|
||||||
window.addEventListener('dragenter', this.dragenter)
|
window.addEventListener('dragenter', this.dragenter)
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,8 @@
|
||||||
"ButtonUploadCover": "Upload Cover",
|
"ButtonUploadCover": "Upload Cover",
|
||||||
"ButtonUploadOPMLFile": "Upload OPML File",
|
"ButtonUploadOPMLFile": "Upload OPML File",
|
||||||
"ButtonUserDelete": "Delete user {0}",
|
"ButtonUserDelete": "Delete user {0}",
|
||||||
|
"LabelOptional": "(optional)",
|
||||||
|
"LabelDirectoryPlaceholder": "Directory/Placeholder",
|
||||||
"ButtonUserEdit": "Edit user {0}",
|
"ButtonUserEdit": "Edit user {0}",
|
||||||
"ButtonViewAll": "View All",
|
"ButtonViewAll": "View All",
|
||||||
"ButtonYes": "Yes",
|
"ButtonYes": "Yes",
|
||||||
|
|
@ -963,6 +965,12 @@
|
||||||
"PlaceholderNewPlaylist": "New playlist name",
|
"PlaceholderNewPlaylist": "New playlist name",
|
||||||
"PlaceholderSearch": "Search..",
|
"PlaceholderSearch": "Search..",
|
||||||
"PlaceholderSearchEpisode": "Search episode..",
|
"PlaceholderSearchEpisode": "Search episode..",
|
||||||
|
"ButtonAddPlaceholder": "Add Placeholder",
|
||||||
|
"LabelPlaceholder": "Placeholder",
|
||||||
|
"LabelPlaceholderDefaultTitle": "Placeholder",
|
||||||
|
"LabelPlaceholderComingSoon": "Coming Soon",
|
||||||
|
"ToastPlaceholderCreated": "Placeholder created",
|
||||||
|
"ToastPlaceholderCreateFailed": "Failed to create placeholder",
|
||||||
"StatsAuthorsAdded": "authors added",
|
"StatsAuthorsAdded": "authors added",
|
||||||
"StatsBooksAdded": "books added",
|
"StatsBooksAdded": "books added",
|
||||||
"StatsBooksAdditional": "Some additions include…",
|
"StatsBooksAdditional": "Some additions include…",
|
||||||
|
|
@ -1161,5 +1169,11 @@
|
||||||
"TooltipLockChapter": "Lock chapter (Shift+click for range)",
|
"TooltipLockChapter": "Lock chapter (Shift+click for range)",
|
||||||
"TooltipSubtractOneSecond": "Subtract 1 second",
|
"TooltipSubtractOneSecond": "Subtract 1 second",
|
||||||
"TooltipUnlockAllChapters": "Unlock all chapters",
|
"TooltipUnlockAllChapters": "Unlock all chapters",
|
||||||
"TooltipUnlockChapter": "Unlock chapter (Shift+click for range)"
|
"TooltipUnlockChapter": "Unlock chapter (Shift+click for range)",
|
||||||
|
"ButtonAddPlaceholder": "Add Placeholder",
|
||||||
|
"LabelPlaceholder": "Placeholder",
|
||||||
|
"LabelPlaceholderDefaultTitle": "Placeholder",
|
||||||
|
"LabelPlaceholderComingSoon": "Coming Soon",
|
||||||
|
"ToastPlaceholderCreated": "Placeholder created",
|
||||||
|
"ToastPlaceholderCreateFailed": "Failed to create placeholder"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.32.1",
|
"version": "2.32.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.32.1",
|
"version": "2.32.2",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.32.1",
|
"version": "2.32.2",
|
||||||
"buildNumber": 1,
|
"buildNumber": 1,
|
||||||
"description": "Self-hosted audiobook and podcast server",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
|
@ -35,6 +35,11 @@
|
||||||
"mocha": {
|
"mocha": {
|
||||||
"recursive": true
|
"recursive": true
|
||||||
},
|
},
|
||||||
|
"nyc": {
|
||||||
|
"exclude": [
|
||||||
|
"server/libs/umzug/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
"author": "advplyr",
|
"author": "advplyr",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -123,9 +123,19 @@ class FileSystemController {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await fs.pathExists(filepath)) {
|
if (await fs.pathExists(filepath)) {
|
||||||
return res.json({
|
// Allow placeholder folders to be targeted for uploads without loosening other paths.
|
||||||
exists: true
|
const placeholderItem = await Database.libraryItemModel.findOne({
|
||||||
|
where: {
|
||||||
|
path: filepath,
|
||||||
|
libraryId: libraryFolder.libraryId,
|
||||||
|
isPlaceholder: true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
if (!placeholderItem) {
|
||||||
|
return res.json({
|
||||||
|
exists: true
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a library item exists in a subdirectory
|
// Check if a library item exists in a subdirectory
|
||||||
|
|
@ -146,7 +156,7 @@ class FileSystemController {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (libraryItem) {
|
if (libraryItem && !libraryItem.isPlaceholder) {
|
||||||
return res.json({
|
return res.json({
|
||||||
exists: true,
|
exists: true,
|
||||||
libraryItemTitle: libraryItem.title
|
libraryItemTitle: libraryItem.title
|
||||||
|
|
|
||||||
|
|
@ -618,6 +618,7 @@ class LibraryController {
|
||||||
mediaType: req.library.mediaType,
|
mediaType: req.library.mediaType,
|
||||||
minified: req.query.minified === '1',
|
minified: req.query.minified === '1',
|
||||||
collapseseries: req.query.collapseseries === '1',
|
collapseseries: req.query.collapseseries === '1',
|
||||||
|
includePlaceholders: include.includes('placeholders'),
|
||||||
include: include.join(',')
|
include: include.join(',')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ const Watcher = require('../Watcher')
|
||||||
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
||||||
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
||||||
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
|
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
|
||||||
const { sanitizeFilename } = require('../utils/fileUtils')
|
const fileUtils = require('../utils/fileUtils')
|
||||||
|
const { sanitizeFilename } = fileUtils
|
||||||
|
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
||||||
|
const LibraryScanner = require('../scanner/LibraryScanner')
|
||||||
|
|
||||||
const TaskManager = require('../managers/TaskManager')
|
const TaskManager = require('../managers/TaskManager')
|
||||||
const adminStats = require('../utils/queries/adminStats')
|
const adminStats = require('../utils/queries/adminStats')
|
||||||
|
|
@ -43,7 +46,7 @@ class MiscController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = Object.values(req.files)
|
const files = Object.values(req.files)
|
||||||
let { title, author, series, folder: folderId, library: libraryId } = req.body
|
let { title, author, series, folder: folderId, library: libraryId, placeholder: placeholderTarget } = req.body
|
||||||
// Validate request body
|
// Validate request body
|
||||||
if (!libraryId || !folderId || typeof libraryId !== 'string' || typeof folderId !== 'string' || !title || typeof title !== 'string') {
|
if (!libraryId || !folderId || typeof libraryId !== 'string' || typeof folderId !== 'string' || !title || typeof title !== 'string') {
|
||||||
return res.status(400).send('Invalid request body')
|
return res.status(400).send('Invalid request body')
|
||||||
|
|
@ -54,6 +57,9 @@ class MiscController {
|
||||||
if (!author || typeof author !== 'string') {
|
if (!author || typeof author !== 'string') {
|
||||||
author = null
|
author = null
|
||||||
}
|
}
|
||||||
|
if (!placeholderTarget || typeof placeholderTarget !== 'string') {
|
||||||
|
placeholderTarget = null
|
||||||
|
}
|
||||||
|
|
||||||
const library = await Database.libraryModel.findByIdWithFolders(libraryId)
|
const library = await Database.libraryModel.findByIdWithFolders(libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
|
|
@ -70,13 +76,56 @@ class MiscController {
|
||||||
return res.status(404).send('Folder not found')
|
return res.status(404).send('Folder not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Podcasts should only be one folder deep
|
let placeholderItem = null
|
||||||
const outputDirectoryParts = library.isPodcast ? [title] : [author, series, title]
|
let outputDirectory = ''
|
||||||
// `.filter(Boolean)` to strip out all the potentially missing details (eg: `author`)
|
|
||||||
// before sanitizing all the directory parts to remove illegal chars and finally prepending
|
if (placeholderTarget) {
|
||||||
// the base folder path
|
let placeholderId = null
|
||||||
const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part))
|
let placeholderPath = null
|
||||||
const outputDirectory = Path.join(...[folder.path, ...cleanedOutputDirectoryParts])
|
|
||||||
|
if (placeholderTarget.startsWith('id:')) {
|
||||||
|
placeholderId = placeholderTarget.slice(3)
|
||||||
|
} else if (Path.isAbsolute(placeholderTarget) || placeholderTarget.includes('/') || placeholderTarget.includes('\\')) {
|
||||||
|
placeholderPath = placeholderTarget
|
||||||
|
} else {
|
||||||
|
placeholderId = placeholderTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placeholderId) {
|
||||||
|
placeholderItem = await Database.libraryItemModel.findByPk(placeholderId)
|
||||||
|
}
|
||||||
|
if (!placeholderItem && placeholderPath) {
|
||||||
|
const normalizedPlaceholderPath = fileUtils.filePathToPOSIX(Path.normalize(placeholderPath))
|
||||||
|
placeholderItem = await Database.libraryItemModel.findOne({
|
||||||
|
where: {
|
||||||
|
path: normalizedPlaceholderPath,
|
||||||
|
libraryId: library.id,
|
||||||
|
isPlaceholder: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!placeholderItem || !placeholderItem.isPlaceholder) {
|
||||||
|
Logger.error(`[MiscController] Invalid placeholder target "${placeholderTarget}" for library "${library.id}"`)
|
||||||
|
return res.status(404).send('Placeholder not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placeholderItem.libraryId !== library.id || placeholderItem.libraryFolderId !== folder.id) {
|
||||||
|
Logger.error(`[MiscController] Placeholder target "${placeholderItem.id}" does not belong to library "${library.id}" folder "${folder.id}"`)
|
||||||
|
return res.status(400).send('Placeholder does not belong to library folder')
|
||||||
|
}
|
||||||
|
|
||||||
|
outputDirectory = placeholderItem.path
|
||||||
|
// Placeholder uploads skip directory building and always target the placeholder folder.
|
||||||
|
} else {
|
||||||
|
// Podcasts should only be one folder deep
|
||||||
|
const outputDirectoryParts = library.isPodcast ? [title] : [author, series, title]
|
||||||
|
// `.filter(Boolean)` to strip out all the potentially missing details (eg: `author`)
|
||||||
|
// before sanitizing all the directory parts to remove illegal chars and finally prepending
|
||||||
|
// the base folder path
|
||||||
|
const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part))
|
||||||
|
outputDirectory = Path.join(...[folder.path, ...cleanedOutputDirectoryParts])
|
||||||
|
}
|
||||||
|
|
||||||
await fs.ensureDir(outputDirectory)
|
await fs.ensureDir(outputDirectory)
|
||||||
|
|
||||||
|
|
@ -96,6 +145,18 @@ class MiscController {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (placeholderItem) {
|
||||||
|
// Promote placeholder and attach files when upload targets a placeholder folder.
|
||||||
|
const updateDetails = {
|
||||||
|
libraryFolderId: folder.id,
|
||||||
|
relPath: placeholderItem.relPath,
|
||||||
|
path: outputDirectory,
|
||||||
|
isFile: placeholderItem.isFile
|
||||||
|
}
|
||||||
|
await LibraryScanner.promotePlaceholder(placeholderItem)
|
||||||
|
await LibraryItemScanner.scanLibraryItem(placeholderItem.id, updateDetails)
|
||||||
|
}
|
||||||
|
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const { Request, Response, NextFunction } = require('express')
|
const { Request, Response, NextFunction } = require('express')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const fs = require('../libs/fsExtra')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const { getTitleIgnorePrefix } = require('../utils')
|
const { getTitleIgnorePrefix } = require('../utils')
|
||||||
|
|
@ -128,11 +129,11 @@ class SeriesController {
|
||||||
const requestedTitle = typeof req.body?.title === 'string' ? req.body.title.trim() : ''
|
const requestedTitle = typeof req.body?.title === 'string' ? req.body.title.trim() : ''
|
||||||
const placeholderTitle = requestedTitle || 'Placeholder'
|
const placeholderTitle = requestedTitle || 'Placeholder'
|
||||||
const requestedSequence = req.body?.sequence
|
const requestedSequence = req.body?.sequence
|
||||||
const placeholderSequence =
|
const placeholderSequence = typeof requestedSequence === 'string' || typeof requestedSequence === 'number' ? String(requestedSequence).trim() || null : null
|
||||||
typeof requestedSequence === 'string' || typeof requestedSequence === 'number' ? String(requestedSequence).trim() || null : null
|
|
||||||
|
|
||||||
const requestedFolderId = typeof req.body?.folderId === 'string' ? req.body.folderId.trim() : ''
|
const requestedFolderId = typeof req.body?.folderId === 'string' ? req.body.folderId.trim() : ''
|
||||||
let libraryFolder = null
|
let libraryFolder = null
|
||||||
|
let seriesLibraryItem = null
|
||||||
|
|
||||||
if (requestedFolderId) {
|
if (requestedFolderId) {
|
||||||
libraryFolder = library.libraryFolders?.find((folder) => folder.id === requestedFolderId)
|
libraryFolder = library.libraryFolders?.find((folder) => folder.id === requestedFolderId)
|
||||||
|
|
@ -140,7 +141,7 @@ class SeriesController {
|
||||||
return res.status(404).send('Folder not found')
|
return res.status(404).send('Folder not found')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const seriesLibraryItem = await Database.libraryItemModel.findOne({
|
seriesLibraryItem = await Database.libraryItemModel.findOne({
|
||||||
where: {
|
where: {
|
||||||
libraryId: library.id,
|
libraryId: library.id,
|
||||||
mediaType: 'book'
|
mediaType: 'book'
|
||||||
|
|
@ -180,7 +181,8 @@ class SeriesController {
|
||||||
return res.status(400).send('Library has no folders')
|
return res.status(400).send('Library has no folders')
|
||||||
}
|
}
|
||||||
|
|
||||||
const outputDirectoryParts = [series.name, placeholderTitle]
|
const authorDirectory = typeof seriesLibraryItem?.authorNamesFirstLast === 'string' ? seriesLibraryItem.authorNamesFirstLast.trim() : ''
|
||||||
|
const outputDirectoryParts = [authorDirectory, series.name, placeholderTitle]
|
||||||
const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part))
|
const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part))
|
||||||
const outputDirectory = filePathToPOSIX(Path.join(...[libraryFolder.path, ...cleanedOutputDirectoryParts]))
|
const outputDirectory = filePathToPOSIX(Path.join(...[libraryFolder.path, ...cleanedOutputDirectoryParts]))
|
||||||
|
|
||||||
|
|
@ -193,6 +195,13 @@ class SeriesController {
|
||||||
return res.status(400).send('Library item already exists at that path')
|
return res.status(400).send('Library item already exists at that path')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.ensureDir(outputDirectory)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[SeriesController] Failed to create placeholder directory "${outputDirectory}"`, error)
|
||||||
|
return res.status(500).send('Failed to create placeholder directory')
|
||||||
|
}
|
||||||
|
|
||||||
const libraryItemFolderStats = {
|
const libraryItemFolderStats = {
|
||||||
ino: null,
|
ino: null,
|
||||||
mtimeMs: 0,
|
mtimeMs: 0,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ module.exports = {
|
||||||
* @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>}
|
* @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>}
|
||||||
*/
|
*/
|
||||||
async getFilteredLibraryItems(libraryId, user, options) {
|
async getFilteredLibraryItems(libraryId, user, options) {
|
||||||
const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include, mediaType } = options
|
const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include, mediaType, includePlaceholders } = options
|
||||||
|
|
||||||
let filterValue = null
|
let filterValue = null
|
||||||
let filterGroup = null
|
let filterGroup = null
|
||||||
|
|
@ -34,7 +34,7 @@ module.exports = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaType === 'book') {
|
if (mediaType === 'book') {
|
||||||
return libraryItemsBookFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset)
|
return libraryItemsBookFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset, false, includePlaceholders)
|
||||||
} else {
|
} else {
|
||||||
return libraryItemsPodcastFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset)
|
return libraryItemsPodcastFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,7 @@ module.exports = {
|
||||||
*/
|
*/
|
||||||
async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) {
|
async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) {
|
||||||
const allSeries = await Database.seriesModel.findAll({
|
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, libraryItems li WHERE bs.seriesId = series.id AND li.mediaId = bs.bookId AND li.mediaType = \"book\" AND li.isPlaceholder = 0)'), 'numBooks']],
|
||||||
distinct: true,
|
distinct: true,
|
||||||
subQuery: false,
|
subQuery: false,
|
||||||
where: seriesWhere,
|
where: seriesWhere,
|
||||||
|
|
@ -395,7 +395,7 @@ module.exports = {
|
||||||
* @param {boolean} isHomePage for home page shelves
|
* @param {boolean} isHomePage for home page shelves
|
||||||
* @returns {{ libraryItems: import('../../models/LibraryItem')[], count: number }}
|
* @returns {{ libraryItems: import('../../models/LibraryItem')[], count: number }}
|
||||||
*/
|
*/
|
||||||
async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset, isHomePage = false) {
|
async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset, isHomePage = false, includePlaceholders = false) {
|
||||||
// TODO: Handle collapse sub-series
|
// TODO: Handle collapse sub-series
|
||||||
if (filterGroup === 'series' && collapseseries) {
|
if (filterGroup === 'series' && collapseseries) {
|
||||||
collapseseries = false
|
collapseseries = false
|
||||||
|
|
@ -408,7 +408,7 @@ module.exports = {
|
||||||
|
|
||||||
let bookAttributes = null
|
let bookAttributes = null
|
||||||
|
|
||||||
const allowPlaceholderItems = filterGroup === 'series' || filterGroup === 'authors'
|
const allowPlaceholderItems = includePlaceholders || filterGroup === 'series' || filterGroup === 'authors'
|
||||||
const libraryItemWhere = {
|
const libraryItemWhere = {
|
||||||
libraryId,
|
libraryId,
|
||||||
...(allowPlaceholderItems ? {} : { isPlaceholder: false })
|
...(allowPlaceholderItems ? {} : { isPlaceholder: false })
|
||||||
|
|
@ -1268,7 +1268,7 @@ module.exports = {
|
||||||
* @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>}
|
* @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>}
|
||||||
*/
|
*/
|
||||||
async getBookLibraryStats(libraryId) {
|
async getBookLibraryStats(libraryId) {
|
||||||
const [statResults] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, SUM(json_array_length(b.audioFiles)) AS numAudioFiles, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.libraryId = :libraryId;`, {
|
const [statResults] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, SUM(json_array_length(b.audioFiles)) AS numAudioFiles, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.libraryId = :libraryId AND li.isPlaceholder = 0;`, {
|
||||||
replacements: {
|
replacements: {
|
||||||
libraryId
|
libraryId
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,7 @@ module.exports = {
|
||||||
// Handle sort order
|
// Handle sort order
|
||||||
const dir = sortDesc ? 'DESC' : 'ASC'
|
const dir = sortDesc ? 'DESC' : 'ASC'
|
||||||
if (sortBy === 'numBooks') {
|
if (sortBy === 'numBooks') {
|
||||||
seriesAttributes.include.push([Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks'])
|
seriesAttributes.include.push([Sequelize.literal('(SELECT count(*) FROM bookSeries bs, libraryItems li WHERE bs.seriesId = series.id AND li.mediaId = bs.bookId AND li.mediaType = "book" AND li.isPlaceholder = 0)'), 'numBooks'])
|
||||||
order.push(['numBooks', dir])
|
order.push(['numBooks', dir])
|
||||||
} else if (sortBy === 'addedAt') {
|
} else if (sortBy === 'addedAt') {
|
||||||
order.push(['createdAt', dir])
|
order.push(['createdAt', dir])
|
||||||
|
|
@ -207,6 +207,7 @@ module.exports = {
|
||||||
const oldLibraryItem = libraryItem.toOldJSONMinified()
|
const oldLibraryItem = libraryItem.toOldJSONMinified()
|
||||||
return oldLibraryItem
|
return oldLibraryItem
|
||||||
})
|
})
|
||||||
|
oldSeries.numBooks = oldSeries.books.filter((book) => !book.isPlaceholder).length
|
||||||
allOldSeries.push(oldSeries)
|
allOldSeries.push(oldSeries)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
||||||
const Auth = require('../../../server/Auth')
|
const Auth = require('../../../server/Auth')
|
||||||
const Logger = require('../../../server/Logger')
|
const Logger = require('../../../server/Logger')
|
||||||
const User = require('../../../server/models/User')
|
const User = require('../../../server/models/User')
|
||||||
|
const fs = require('../../../server/libs/fsExtra')
|
||||||
|
|
||||||
describe('SeriesController placeholders', () => {
|
describe('SeriesController placeholders', () => {
|
||||||
/** @type {ApiRouter} */
|
/** @type {ApiRouter} */
|
||||||
|
|
@ -31,6 +32,7 @@ describe('SeriesController placeholders', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
sinon.stub(Logger, 'warn')
|
sinon.stub(Logger, 'warn')
|
||||||
|
sinon.stub(fs, 'ensureDir').resolves()
|
||||||
|
|
||||||
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||||
libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })
|
libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })
|
||||||
|
|
@ -78,6 +80,71 @@ describe('SeriesController placeholders', () => {
|
||||||
expect(payload.media.metadata.series.some((entry) => entry.id === series.id)).to.be.true
|
expect(payload.media.metadata.series.some((entry) => entry.id === series.id)).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('creates placeholder directories on disk', async () => {
|
||||||
|
const fakeReq = {
|
||||||
|
params: {
|
||||||
|
id: library.id,
|
||||||
|
seriesId: series.id
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
body: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeRes = {
|
||||||
|
status: sinon.stub().returnsThis(),
|
||||||
|
json: sinon.spy(),
|
||||||
|
sendStatus: sinon.spy(),
|
||||||
|
send: sinon.spy()
|
||||||
|
}
|
||||||
|
|
||||||
|
await SeriesController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fs.ensureDir.calledOnce).to.be.true
|
||||||
|
expect(fs.ensureDir.firstCall.args[0]).to.equal('/test/Test Series/Placeholder')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes the author folder when creating placeholders for a series item with authors', async () => {
|
||||||
|
const existingBook = await Database.bookModel.create({
|
||||||
|
title: 'Existing Book',
|
||||||
|
audioFiles: [],
|
||||||
|
tags: [],
|
||||||
|
narrators: [],
|
||||||
|
genres: [],
|
||||||
|
chapters: []
|
||||||
|
})
|
||||||
|
await Database.bookSeriesModel.create({ bookId: existingBook.id, seriesId: series.id })
|
||||||
|
await Database.libraryItemModel.create({
|
||||||
|
libraryFiles: [],
|
||||||
|
mediaId: existingBook.id,
|
||||||
|
mediaType: 'book',
|
||||||
|
libraryId: library.id,
|
||||||
|
libraryFolderId: libraryFolder.id,
|
||||||
|
authorNamesFirstLast: 'Jane Doe',
|
||||||
|
authorNamesLastFirst: 'Doe, Jane'
|
||||||
|
})
|
||||||
|
|
||||||
|
const fakeReq = {
|
||||||
|
params: {
|
||||||
|
id: library.id,
|
||||||
|
seriesId: series.id
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
body: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeRes = {
|
||||||
|
status: sinon.stub().returnsThis(),
|
||||||
|
json: sinon.spy(),
|
||||||
|
sendStatus: sinon.spy(),
|
||||||
|
send: sinon.spy()
|
||||||
|
}
|
||||||
|
|
||||||
|
await SeriesController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fs.ensureDir.calledOnce).to.be.true
|
||||||
|
expect(fs.ensureDir.firstCall.args[0]).to.equal('/test/Jane Doe/Test Series/Placeholder')
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects cover url payloads for placeholder creation', async () => {
|
it('rejects cover url payloads for placeholder creation', async () => {
|
||||||
const fakeReq = {
|
const fakeReq = {
|
||||||
params: {
|
params: {
|
||||||
|
|
@ -216,4 +283,30 @@ describe('SeriesController placeholders', () => {
|
||||||
expect(payload.folderId).to.equal(libraryFolder.id)
|
expect(payload.folderId).to.equal(libraryFolder.id)
|
||||||
expect(payload.path.startsWith(libraryFolder.path)).to.be.true
|
expect(payload.path.startsWith(libraryFolder.path)).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('falls back to the first library folder when the series has no items', async () => {
|
||||||
|
const fakeReq = {
|
||||||
|
params: {
|
||||||
|
id: library.id,
|
||||||
|
seriesId: series.id
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
body: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeRes = {
|
||||||
|
status: sinon.stub().returnsThis(),
|
||||||
|
json: sinon.spy(),
|
||||||
|
sendStatus: sinon.spy(),
|
||||||
|
send: sinon.spy()
|
||||||
|
}
|
||||||
|
|
||||||
|
await SeriesController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeRes.json.calledOnce).to.be.true
|
||||||
|
const payload = fakeRes.json.firstCall.args[0]
|
||||||
|
expect(payload.folderId).to.equal(libraryFolder.id)
|
||||||
|
expect(payload.path.startsWith(libraryFolder.path)).to.be.true
|
||||||
|
expect(payload.path).to.include('/Test Series/Placeholder')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ describe('LibraryScanner placeholder handling', () => {
|
||||||
path: '/library/Series/Placeholder',
|
path: '/library/Series/Placeholder',
|
||||||
relPath: 'Series/Placeholder',
|
relPath: 'Series/Placeholder',
|
||||||
isPlaceholder: true,
|
isPlaceholder: true,
|
||||||
isMissing: false,
|
isMissing: true,
|
||||||
media: { title: 'Placeholder Book' },
|
media: { title: 'Placeholder Book' },
|
||||||
save: sinon.stub().resolves(),
|
save: sinon.stub().resolves(),
|
||||||
changed: sinon.stub()
|
changed: sinon.stub()
|
||||||
|
|
@ -118,6 +118,7 @@ describe('LibraryScanner placeholder handling', () => {
|
||||||
const results = await LibraryScanner.scanFolderUpdates(library, folder, fileUpdateGroup)
|
const results = await LibraryScanner.scanFolderUpdates(library, folder, fileUpdateGroup)
|
||||||
|
|
||||||
expect(placeholderItem.isPlaceholder).to.be.false
|
expect(placeholderItem.isPlaceholder).to.be.false
|
||||||
|
expect(placeholderItem.isMissing).to.be.false
|
||||||
expect(placeholderItem.save.calledOnce).to.be.true
|
expect(placeholderItem.save.calledOnce).to.be.true
|
||||||
expect(scanLibraryItemStub.calledOnce).to.be.true
|
expect(scanLibraryItemStub.calledOnce).to.be.true
|
||||||
expect(results['Series/Placeholder']).to.equal(ScanResult.UPDATED)
|
expect(results['Series/Placeholder']).to.equal(ScanResult.UPDATED)
|
||||||
|
|
@ -145,7 +146,7 @@ describe('LibraryScanner placeholder handling', () => {
|
||||||
path: '/library/Series/Placeholder',
|
path: '/library/Series/Placeholder',
|
||||||
relPath: 'Series/Placeholder',
|
relPath: 'Series/Placeholder',
|
||||||
isPlaceholder: true,
|
isPlaceholder: true,
|
||||||
isMissing: false,
|
isMissing: true,
|
||||||
save: sinon.stub().resolves(),
|
save: sinon.stub().resolves(),
|
||||||
changed: sinon.stub()
|
changed: sinon.stub()
|
||||||
}
|
}
|
||||||
|
|
@ -162,14 +163,16 @@ describe('LibraryScanner placeholder handling', () => {
|
||||||
sinon.stub(libraryFilters, 'getFilterData').resolves()
|
sinon.stub(libraryFilters, 'getFilterData').resolves()
|
||||||
sinon.stub(LibraryScanner, 'scanFolder').resolves([libraryItemData])
|
sinon.stub(LibraryScanner, 'scanFolder').resolves([libraryItemData])
|
||||||
sinon.stub(Database.libraryItemModel, 'findAll').resolves([placeholderItem])
|
sinon.stub(Database.libraryItemModel, 'findAll').resolves([placeholderItem])
|
||||||
sinon.stub(LibraryItemScanner, 'rescanLibraryItemMedia').resolves({ libraryItem: placeholderItem, wasUpdated: true })
|
const rescanStub = sinon.stub(LibraryItemScanner, 'rescanLibraryItemMedia').resolves({ libraryItem: placeholderItem, wasUpdated: true })
|
||||||
sinon.stub(LibraryItemScanner, 'checkAuthorsAndSeriesRemovedFromBooks').resolves()
|
sinon.stub(LibraryItemScanner, 'checkAuthorsAndSeriesRemovedFromBooks').resolves()
|
||||||
sinon.stub(SocketAuthority, 'libraryItemsEmitter')
|
sinon.stub(SocketAuthority, 'libraryItemsEmitter')
|
||||||
|
|
||||||
await LibraryScanner.scanLibrary(libraryScan, false)
|
await LibraryScanner.scanLibrary(libraryScan, false)
|
||||||
|
|
||||||
expect(placeholderItem.isPlaceholder).to.be.false
|
expect(placeholderItem.isPlaceholder).to.be.false
|
||||||
|
expect(placeholderItem.isMissing).to.be.false
|
||||||
expect(placeholderItem.save.calledOnce).to.be.true
|
expect(placeholderItem.save.calledOnce).to.be.true
|
||||||
|
expect(rescanStub.calledOnce).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('skips placeholder scans on file updates without audio', async () => {
|
it('skips placeholder scans on file updates without audio', async () => {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ describe('libraryItemsBookFilters placeholders', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||||
|
global.ServerSettings = { sortingIgnorePrefix: false }
|
||||||
await Database.buildModels()
|
await Database.buildModels()
|
||||||
|
|
||||||
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||||
|
|
@ -54,11 +55,17 @@ describe('libraryItemsBookFilters placeholders', () => {
|
||||||
return { book, libraryItem }
|
return { book, libraryItem }
|
||||||
}
|
}
|
||||||
|
|
||||||
const createSeriesWithBook = async ({ seriesName, title, isPlaceholder }) => {
|
const createSeriesWithBook = async ({ seriesName, seriesId, title, isPlaceholder }) => {
|
||||||
const series = await Database.seriesModel.create({
|
let series = null
|
||||||
name: seriesName,
|
if (seriesId) {
|
||||||
libraryId: library.id
|
series = await Database.seriesModel.findByPk(seriesId)
|
||||||
})
|
}
|
||||||
|
if (!series) {
|
||||||
|
series = await Database.seriesModel.create({
|
||||||
|
name: seriesName,
|
||||||
|
libraryId: library.id
|
||||||
|
})
|
||||||
|
}
|
||||||
const { book, libraryItem } = await createBookWithItem({ title, isPlaceholder })
|
const { book, libraryItem } = await createBookWithItem({ title, isPlaceholder })
|
||||||
await Database.bookSeriesModel.create({
|
await Database.bookSeriesModel.create({
|
||||||
seriesId: series.id,
|
seriesId: series.id,
|
||||||
|
|
@ -68,11 +75,17 @@ describe('libraryItemsBookFilters placeholders', () => {
|
||||||
return { series, book, libraryItem }
|
return { series, book, libraryItem }
|
||||||
}
|
}
|
||||||
|
|
||||||
const createAuthorWithBook = async ({ authorName, title, isPlaceholder }) => {
|
const createAuthorWithBook = async ({ authorName, authorId, title, isPlaceholder }) => {
|
||||||
const author = await Database.authorModel.create({
|
let author = null
|
||||||
name: authorName,
|
if (authorId) {
|
||||||
libraryId: library.id
|
author = await Database.authorModel.findByPk(authorId)
|
||||||
})
|
}
|
||||||
|
if (!author) {
|
||||||
|
author = await Database.authorModel.create({
|
||||||
|
name: authorName,
|
||||||
|
libraryId: library.id
|
||||||
|
})
|
||||||
|
}
|
||||||
const { book, libraryItem } = await createBookWithItem({ title, isPlaceholder })
|
const { book, libraryItem } = await createBookWithItem({ title, isPlaceholder })
|
||||||
await Database.bookAuthorModel.create({
|
await Database.bookAuthorModel.create({
|
||||||
authorId: author.id,
|
authorId: author.id,
|
||||||
|
|
@ -85,18 +98,7 @@ describe('libraryItemsBookFilters placeholders', () => {
|
||||||
await createBookWithItem({ title: 'Real Book', isPlaceholder: false })
|
await createBookWithItem({ title: 'Real Book', isPlaceholder: false })
|
||||||
await createBookWithItem({ title: 'Placeholder Book', isPlaceholder: true })
|
await createBookWithItem({ title: 'Placeholder Book', isPlaceholder: true })
|
||||||
|
|
||||||
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(
|
const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, null, null, 'addedAt', true, false, [], 20, 0)
|
||||||
library.id,
|
|
||||||
user,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
'addedAt',
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
[],
|
|
||||||
20,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(count).to.equal(1)
|
expect(count).to.equal(1)
|
||||||
expect(libraryItems).to.have.length(1)
|
expect(libraryItems).to.have.length(1)
|
||||||
|
|
@ -115,7 +117,7 @@ describe('libraryItemsBookFilters placeholders', () => {
|
||||||
it('includes placeholders when fetching items for a series', async () => {
|
it('includes placeholders when fetching items for a series', async () => {
|
||||||
const real = await createSeriesWithBook({ seriesName: 'Series A', title: 'Real Book', isPlaceholder: false })
|
const real = await createSeriesWithBook({ seriesName: 'Series A', title: 'Real Book', isPlaceholder: false })
|
||||||
await createBookWithItem({ title: 'Standalone Book', isPlaceholder: false })
|
await createBookWithItem({ title: 'Standalone Book', isPlaceholder: false })
|
||||||
await createSeriesWithBook({ seriesName: 'Series A', title: 'Placeholder Book', isPlaceholder: true })
|
await createSeriesWithBook({ seriesId: real.series.id, title: 'Placeholder Book', isPlaceholder: true })
|
||||||
|
|
||||||
const items = await libraryItemsBookFilters.getLibraryItemsForSeries(real.series, user)
|
const items = await libraryItemsBookFilters.getLibraryItemsForSeries(real.series, user)
|
||||||
|
|
||||||
|
|
@ -127,7 +129,7 @@ describe('libraryItemsBookFilters placeholders', () => {
|
||||||
it('includes placeholders when fetching items for an author', async () => {
|
it('includes placeholders when fetching items for an author', async () => {
|
||||||
const real = await createAuthorWithBook({ authorName: 'Author A', title: 'Real Book', isPlaceholder: false })
|
const real = await createAuthorWithBook({ authorName: 'Author A', title: 'Real Book', isPlaceholder: false })
|
||||||
await createBookWithItem({ title: 'Standalone Book', isPlaceholder: false })
|
await createBookWithItem({ title: 'Standalone Book', isPlaceholder: false })
|
||||||
await createAuthorWithBook({ authorName: 'Author A', title: 'Placeholder Book', isPlaceholder: true })
|
await createAuthorWithBook({ authorId: real.author.id, title: 'Placeholder Book', isPlaceholder: true })
|
||||||
|
|
||||||
const { libraryItems, count } = await libraryFilters.getLibraryItemsForAuthor(real.author, user, 20, 0)
|
const { libraryItems, count } = await libraryFilters.getLibraryItemsForAuthor(real.author, user, 20, 0)
|
||||||
|
|
||||||
|
|
@ -135,4 +137,58 @@ describe('libraryItemsBookFilters placeholders', () => {
|
||||||
const titles = libraryItems.map((item) => item.media.title).sort()
|
const titles = libraryItems.map((item) => item.media.title).sort()
|
||||||
expect(titles).to.deep.equal(['Placeholder Book', 'Real Book'])
|
expect(titles).to.deep.equal(['Placeholder Book', 'Real Book'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('includes placeholders only when includePlaceholders option is enabled', async () => {
|
||||||
|
await createBookWithItem({ title: 'Placeholder Book', isPlaceholder: true })
|
||||||
|
|
||||||
|
const excluded = await libraryFilters.getFilteredLibraryItems(library.id, user, {
|
||||||
|
filterBy: null,
|
||||||
|
sortBy: 'addedAt',
|
||||||
|
sortDesc: true,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
collapseseries: false,
|
||||||
|
include: [],
|
||||||
|
mediaType: 'book',
|
||||||
|
includePlaceholders: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const included = await libraryFilters.getFilteredLibraryItems(library.id, user, {
|
||||||
|
filterBy: null,
|
||||||
|
sortBy: 'addedAt',
|
||||||
|
sortDesc: true,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
collapseseries: false,
|
||||||
|
include: [],
|
||||||
|
mediaType: 'book',
|
||||||
|
includePlaceholders: true
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(excluded.count).to.equal(0)
|
||||||
|
expect(excluded.libraryItems).to.have.length(0)
|
||||||
|
expect(included.count).to.equal(1)
|
||||||
|
expect(included.libraryItems).to.have.length(1)
|
||||||
|
expect(included.libraryItems[0].media.title).to.equal('Placeholder Book')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes placeholders from library stats totals', async () => {
|
||||||
|
await createBookWithItem({ title: 'Real Book', isPlaceholder: false })
|
||||||
|
await createBookWithItem({ title: 'Placeholder Book', isPlaceholder: true })
|
||||||
|
|
||||||
|
const stats = await libraryItemsBookFilters.getBookLibraryStats(library.id)
|
||||||
|
|
||||||
|
expect(stats.totalItems).to.equal(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses only real books in collapsed series numBooks', async () => {
|
||||||
|
const real = await createSeriesWithBook({ seriesName: 'Series A', title: 'Real Book', isPlaceholder: false })
|
||||||
|
await createSeriesWithBook({ seriesId: real.series.id, title: 'Placeholder Book', isPlaceholder: true })
|
||||||
|
|
||||||
|
const { libraryItems } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, null, null, 'media.metadata.title', false, true, [], 20, 0)
|
||||||
|
|
||||||
|
expect(libraryItems).to.have.length(1)
|
||||||
|
expect(libraryItems[0].collapsedSeries).to.exist
|
||||||
|
expect(libraryItems[0].collapsedSeries.numBooks).to.equal(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue