feat(client): add placeholder UX for series/upload flows, card styling/action guards, and localized strings

This commit is contained in:
Jens Bickel 2026-02-09 17:19:21 +01:00
parent 2a775d434a
commit fc325a4095
23 changed files with 850 additions and 65 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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')
})
})

View file

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

View file

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

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

View file

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

View file

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

View file

@ -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(',')
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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