+
-
+
@@ -291,9 +296,15 @@ export default {
// Only added to item object when collapseSeries is enabled
return this.collapsedSeries?.libraryItemIds || []
},
+ isPlaceholder() {
+ return !!this._libraryItem.isPlaceholder
+ },
hasCover() {
return !!this.media.coverPath
},
+ showPlaceholderDetails() {
+ return this.bookCoverSrc === this.placeholderUrl
+ },
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
@@ -436,7 +447,7 @@ export default {
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
},
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() {
return !this.isSelectionMode && this.ebookFormat
@@ -584,7 +595,7 @@ export default {
text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening
})
}
- if (!this.isPodcast) {
+ if (!this.isPodcast && !this.isPlaceholder) {
if (this.libraryItemIdStreaming && !this.isStreamingFromDifferentLibrary) {
if (!this.isQueued) {
items.push({
@@ -1105,3 +1116,56 @@ export default {
}
}
+
+
diff --git a/client/components/cards/LazySeriesCard.vue b/client/components/cards/LazySeriesCard.vue
index 34cea7e22..af7f1ff66 100644
--- a/client/components/cards/LazySeriesCard.vue
+++ b/client/components/cards/LazySeriesCard.vue
@@ -7,7 +7,7 @@
-
{{ books.length }}
+
{{ seriesBooksCount }}
@@ -112,6 +112,10 @@ export default {
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() {
return this.series?.addedAt || 0
},
diff --git a/client/cypress/tests/components/app/BookShelfToolbar.placeholder.cy.js b/client/cypress/tests/components/app/BookShelfToolbar.placeholder.cy.js
new file mode 100644
index 000000000..b8e34b9d5
--- /dev/null
+++ b/client/cypress/tests/components/app/BookShelfToolbar.placeholder.cy.js
@@ -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'
+ })
+ })
+})
diff --git a/client/cypress/tests/components/cards/LazyBookCard.cy.js b/client/cypress/tests/components/cards/LazyBookCard.cy.js
index ab685b0d1..ae1cd8f7c 100644
--- a/client/cypress/tests/components/cards/LazyBookCard.cy.js
+++ b/client/cypress/tests/components/cards/LazyBookCard.cy.js
@@ -36,6 +36,9 @@ function createMountOptions() {
$config: {
routerBasePath: 'https://my.server.com'
},
+ $strings: {
+ LabelPlaceholderComingSoon: 'Localized Coming Soon'
+ },
$store: {
commit: () => {},
getters: {
@@ -163,6 +166,21 @@ describe('LazyBookCard', () => {
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', () => {
mountOptions.mocks.$router = { push: cy.stub().as('routerPush') }
cy.mount(LazyBookCard, mountOptions)
@@ -211,6 +229,16 @@ describe('LazyBookCard', () => {
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', () => {
mountOptions.propsData.bookshelfView = Constants.BookshelfView.STANDARD
cy.mount(LazyBookCard, mountOptions)
diff --git a/client/cypress/tests/components/cards/LazySeriesCard.cy.js b/client/cypress/tests/components/cards/LazySeriesCard.cy.js
index 12eec6924..445f2a9b4 100644
--- a/client/cypress/tests/components/cards/LazySeriesCard.cy.js
+++ b/client/cypress/tests/components/cards/LazySeriesCard.cy.js
@@ -87,6 +87,19 @@ describe('LazySeriesCard', () => {
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', () => {
cy.mount(LazySeriesCard, { propsData, stubs, mocks })
cy.get('&card').trigger('mouseover')
diff --git a/client/cypress/tests/components/pages/Upload.placeholder.cy.js b/client/cypress/tests/components/pages/Upload.placeholder.cy.js
new file mode 100644
index 000000000..0890533bf
--- /dev/null
+++ b/client/cypress/tests/components/pages/Upload.placeholder.cy.js
@@ -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')
+ })
+})
diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue
index adc21ff90..17006c1d1 100644
--- a/client/pages/upload/index.vue
+++ b/client/pages/upload/index.vue
@@ -74,7 +74,20 @@
-
+
@@ -104,6 +117,8 @@ export default {
selectedFolderId: null,
processing: false,
uploadFinished: false,
+ placeholderTargetByFolderId: {},
+ placeholdersByFolderId: {},
fetchMetadata: {
enabled: false,
provider: null
@@ -175,10 +190,22 @@ export default {
},
uploadReady() {
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: {
- libraryChanged() {
+ async libraryChanged() {
if (!this.selectedLibrary && this.selectedFolderId) {
this.selectedFolderId = null
} else if (this.selectedFolderId) {
@@ -188,6 +215,7 @@ export default {
}
this.setDefaultFolder()
this.setMetadataProvider()
+ await this.fetchPlaceholdersForLibrary()
},
setDefaultFolder() {
if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) {
@@ -208,9 +236,45 @@ export default {
this.items = []
this.ignoredFiles = []
this.uploadFinished = false
+ this.placeholderTargetByFolderId = {}
if (this.$refs.fileInput) this.$refs.fileInput.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() {
if (this.$refs.fileInput) this.$refs.fileInput.click()
},
@@ -315,6 +379,9 @@ export default {
}
form.set('library', this.selectedLibraryId)
form.set('folder', this.selectedFolderId)
+ if (this.cleanedPlaceholderTarget) {
+ form.set('placeholder', this.cleanedPlaceholderTarget)
+ }
var index = 0
item.files.forEach((file) => {
@@ -380,6 +447,11 @@ export default {
// Check if path already exists before starting upload
// uploading fails if path already exists
for (const item of items) {
+ if (this.cleanedPlaceholderTarget) {
+ itemsToUpload.push(item)
+ continue
+ }
+
const exists = await this.$axios
.$post(`/api/filesystem/pathexists`, { directory: item.directory, folderPath: this.selectedFolder.fullPath })
.then((data) => {
@@ -415,6 +487,7 @@ export default {
this.setMetadataProvider()
this.setDefaultFolder()
+ this.fetchPlaceholdersForLibrary()
// Fetch providers if not already loaded
this.$store.dispatch('scanners/fetchProviders')
window.addEventListener('dragenter', this.dragenter)
diff --git a/client/strings/en-us.json b/client/strings/en-us.json
index fb2bcb281..809bff009 100644
--- a/client/strings/en-us.json
+++ b/client/strings/en-us.json
@@ -112,6 +112,8 @@
"ButtonUploadCover": "Upload Cover",
"ButtonUploadOPMLFile": "Upload OPML File",
"ButtonUserDelete": "Delete user {0}",
+ "LabelOptional": "(optional)",
+ "LabelDirectoryPlaceholder": "Directory/Placeholder",
"ButtonUserEdit": "Edit user {0}",
"ButtonViewAll": "View All",
"ButtonYes": "Yes",
@@ -963,6 +965,12 @@
"PlaceholderNewPlaylist": "New playlist name",
"PlaceholderSearch": "Search..",
"PlaceholderSearchEpisode": "Search episode..",
+ "ButtonAddPlaceholder": "Add Placeholder",
+ "LabelPlaceholder": "Placeholder",
+ "LabelPlaceholderDefaultTitle": "Placeholder",
+ "LabelPlaceholderComingSoon": "Coming Soon",
+ "ToastPlaceholderCreated": "Placeholder created",
+ "ToastPlaceholderCreateFailed": "Failed to create placeholder",
"StatsAuthorsAdded": "authors added",
"StatsBooksAdded": "books added",
"StatsBooksAdditional": "Some additions include…",
@@ -1161,5 +1169,11 @@
"TooltipLockChapter": "Lock chapter (Shift+click for range)",
"TooltipSubtractOneSecond": "Subtract 1 second",
"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"
}
diff --git a/package-lock.json b/package-lock.json
index 08707893d..1091ceece 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
- "version": "2.32.1",
+ "version": "2.32.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
- "version": "2.32.1",
+ "version": "2.32.2",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
diff --git a/package.json b/package.json
index 3ee3fb391..ccc1688c1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
- "version": "2.32.1",
+ "version": "2.32.2",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
@@ -35,6 +35,11 @@
"mocha": {
"recursive": true
},
+ "nyc": {
+ "exclude": [
+ "server/libs/umzug/**"
+ ]
+ },
"author": "advplyr",
"license": "GPL-3.0",
"dependencies": {
diff --git a/server/controllers/FileSystemController.js b/server/controllers/FileSystemController.js
index 4b0a94b39..6b24c2ace 100644
--- a/server/controllers/FileSystemController.js
+++ b/server/controllers/FileSystemController.js
@@ -123,9 +123,19 @@ class FileSystemController {
}
if (await fs.pathExists(filepath)) {
- return res.json({
- exists: true
+ // Allow placeholder folders to be targeted for uploads without loosening other paths.
+ 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
@@ -146,7 +156,7 @@ class FileSystemController {
}
})
- if (libraryItem) {
+ if (libraryItem && !libraryItem.isPlaceholder) {
return res.json({
exists: true,
libraryItemTitle: libraryItem.title
diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js
index 55ef45690..f578fdee2 100644
--- a/server/controllers/LibraryController.js
+++ b/server/controllers/LibraryController.js
@@ -618,6 +618,7 @@ class LibraryController {
mediaType: req.library.mediaType,
minified: req.query.minified === '1',
collapseseries: req.query.collapseseries === '1',
+ includePlaceholders: include.includes('placeholders'),
include: include.join(',')
}
diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js
index 490cb27d2..923e84eb1 100644
--- a/server/controllers/MiscController.js
+++ b/server/controllers/MiscController.js
@@ -10,7 +10,10 @@ const Watcher = require('../Watcher')
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const patternValidation = require('../libs/nodeCron/pattern-validation')
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 adminStats = require('../utils/queries/adminStats')
@@ -43,7 +46,7 @@ class MiscController {
}
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
if (!libraryId || !folderId || typeof libraryId !== 'string' || typeof folderId !== 'string' || !title || typeof title !== 'string') {
return res.status(400).send('Invalid request body')
@@ -54,6 +57,9 @@ class MiscController {
if (!author || typeof author !== 'string') {
author = null
}
+ if (!placeholderTarget || typeof placeholderTarget !== 'string') {
+ placeholderTarget = null
+ }
const library = await Database.libraryModel.findByIdWithFolders(libraryId)
if (!library) {
@@ -70,13 +76,56 @@ class MiscController {
return res.status(404).send('Folder not found')
}
- // 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))
- const outputDirectory = Path.join(...[folder.path, ...cleanedOutputDirectoryParts])
+ let placeholderItem = null
+ let outputDirectory = ''
+
+ if (placeholderTarget) {
+ let placeholderId = null
+ let placeholderPath = null
+
+ 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)
@@ -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)
}
diff --git a/server/controllers/SeriesController.js b/server/controllers/SeriesController.js
index 93735fc19..72ae730c1 100644
--- a/server/controllers/SeriesController.js
+++ b/server/controllers/SeriesController.js
@@ -1,6 +1,7 @@
const Path = require('path')
const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger')
+const fs = require('../libs/fsExtra')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const { getTitleIgnorePrefix } = require('../utils')
@@ -128,11 +129,11 @@ class SeriesController {
const requestedTitle = typeof req.body?.title === 'string' ? req.body.title.trim() : ''
const placeholderTitle = requestedTitle || 'Placeholder'
const requestedSequence = req.body?.sequence
- const placeholderSequence =
- typeof requestedSequence === 'string' || typeof requestedSequence === 'number' ? String(requestedSequence).trim() || null : null
+ const placeholderSequence = typeof requestedSequence === 'string' || typeof requestedSequence === 'number' ? String(requestedSequence).trim() || null : null
const requestedFolderId = typeof req.body?.folderId === 'string' ? req.body.folderId.trim() : ''
let libraryFolder = null
+ let seriesLibraryItem = null
if (requestedFolderId) {
libraryFolder = library.libraryFolders?.find((folder) => folder.id === requestedFolderId)
@@ -140,7 +141,7 @@ class SeriesController {
return res.status(404).send('Folder not found')
}
} else {
- const seriesLibraryItem = await Database.libraryItemModel.findOne({
+ seriesLibraryItem = await Database.libraryItemModel.findOne({
where: {
libraryId: library.id,
mediaType: 'book'
@@ -180,7 +181,8 @@ class SeriesController {
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 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')
}
+ 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 = {
ino: null,
mtimeMs: 0,
diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js
index cf7e3dbf0..ea68c71f8 100644
--- a/server/utils/queries/libraryFilters.js
+++ b/server/utils/queries/libraryFilters.js
@@ -22,7 +22,7 @@ module.exports = {
* @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>}
*/
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 filterGroup = null
@@ -34,7 +34,7 @@ module.exports = {
}
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 {
return libraryItemsPodcastFilters.getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset)
}
diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js
index 9ff9f751a..44cdad3b0 100644
--- a/server/utils/queries/libraryItemsBookFilters.js
+++ b/server/utils/queries/libraryItemsBookFilters.js
@@ -311,7 +311,7 @@ module.exports = {
*/
async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) {
const allSeries = await Database.seriesModel.findAll({
- attributes: ['id', 'name', [Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks']],
+ attributes: ['id', 'name', [Sequelize.literal('(SELECT count(*) FROM bookSeries bs, libraryItems li WHERE bs.seriesId = series.id AND li.mediaId = bs.bookId AND li.mediaType = \"book\" AND li.isPlaceholder = 0)'), 'numBooks']],
distinct: true,
subQuery: false,
where: seriesWhere,
@@ -395,7 +395,7 @@ module.exports = {
* @param {boolean} isHomePage for home page shelves
* @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
if (filterGroup === 'series' && collapseseries) {
collapseseries = false
@@ -408,7 +408,7 @@ module.exports = {
let bookAttributes = null
- const allowPlaceholderItems = filterGroup === 'series' || filterGroup === 'authors'
+ const allowPlaceholderItems = includePlaceholders || filterGroup === 'series' || filterGroup === 'authors'
const libraryItemWhere = {
libraryId,
...(allowPlaceholderItems ? {} : { isPlaceholder: false })
@@ -1268,7 +1268,7 @@ module.exports = {
* @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>}
*/
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: {
libraryId
}
diff --git a/server/utils/queries/seriesFilters.js b/server/utils/queries/seriesFilters.js
index ed71e5b3f..ba0e25ee6 100644
--- a/server/utils/queries/seriesFilters.js
+++ b/server/utils/queries/seriesFilters.js
@@ -122,7 +122,7 @@ module.exports = {
// Handle sort order
const dir = sortDesc ? 'DESC' : 'ASC'
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])
} else if (sortBy === 'addedAt') {
order.push(['createdAt', dir])
@@ -207,6 +207,7 @@ module.exports = {
const oldLibraryItem = libraryItem.toOldJSONMinified()
return oldLibraryItem
})
+ oldSeries.numBooks = oldSeries.books.filter((book) => !book.isPlaceholder).length
allOldSeries.push(oldSeries)
}
diff --git a/test/server/controllers/SeriesController.placeholders.test.js b/test/server/controllers/SeriesController.placeholders.test.js
index d0379c2f4..a61eebda5 100644
--- a/test/server/controllers/SeriesController.placeholders.test.js
+++ b/test/server/controllers/SeriesController.placeholders.test.js
@@ -9,6 +9,7 @@ const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
const Auth = require('../../../server/Auth')
const Logger = require('../../../server/Logger')
const User = require('../../../server/models/User')
+const fs = require('../../../server/libs/fsExtra')
describe('SeriesController placeholders', () => {
/** @type {ApiRouter} */
@@ -31,6 +32,7 @@ describe('SeriesController placeholders', () => {
})
sinon.stub(Logger, 'warn')
+ sinon.stub(fs, 'ensureDir').resolves()
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
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
})
+ 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 () => {
const fakeReq = {
params: {
@@ -216,4 +283,30 @@ describe('SeriesController placeholders', () => {
expect(payload.folderId).to.equal(libraryFolder.id)
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')
+ })
})
diff --git a/test/server/scanner/LibraryScanner.placeholders.test.js b/test/server/scanner/LibraryScanner.placeholders.test.js
index 3d604fd3c..c7250ddb2 100644
--- a/test/server/scanner/LibraryScanner.placeholders.test.js
+++ b/test/server/scanner/LibraryScanner.placeholders.test.js
@@ -98,7 +98,7 @@ describe('LibraryScanner placeholder handling', () => {
path: '/library/Series/Placeholder',
relPath: 'Series/Placeholder',
isPlaceholder: true,
- isMissing: false,
+ isMissing: true,
media: { title: 'Placeholder Book' },
save: sinon.stub().resolves(),
changed: sinon.stub()
@@ -118,6 +118,7 @@ describe('LibraryScanner placeholder handling', () => {
const results = await LibraryScanner.scanFolderUpdates(library, folder, fileUpdateGroup)
expect(placeholderItem.isPlaceholder).to.be.false
+ expect(placeholderItem.isMissing).to.be.false
expect(placeholderItem.save.calledOnce).to.be.true
expect(scanLibraryItemStub.calledOnce).to.be.true
expect(results['Series/Placeholder']).to.equal(ScanResult.UPDATED)
@@ -145,7 +146,7 @@ describe('LibraryScanner placeholder handling', () => {
path: '/library/Series/Placeholder',
relPath: 'Series/Placeholder',
isPlaceholder: true,
- isMissing: false,
+ isMissing: true,
save: sinon.stub().resolves(),
changed: sinon.stub()
}
@@ -162,14 +163,16 @@ describe('LibraryScanner placeholder handling', () => {
sinon.stub(libraryFilters, 'getFilterData').resolves()
sinon.stub(LibraryScanner, 'scanFolder').resolves([libraryItemData])
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(SocketAuthority, 'libraryItemsEmitter')
await LibraryScanner.scanLibrary(libraryScan, false)
expect(placeholderItem.isPlaceholder).to.be.false
+ expect(placeholderItem.isMissing).to.be.false
expect(placeholderItem.save.calledOnce).to.be.true
+ expect(rescanStub.calledOnce).to.be.true
})
it('skips placeholder scans on file updates without audio', async () => {
diff --git a/test/server/utils/queries/libraryItemsBookFilters.placeholders.test.js b/test/server/utils/queries/libraryItemsBookFilters.placeholders.test.js
index 74723c45a..69913868e 100644
--- a/test/server/utils/queries/libraryItemsBookFilters.placeholders.test.js
+++ b/test/server/utils/queries/libraryItemsBookFilters.placeholders.test.js
@@ -14,6 +14,7 @@ describe('libraryItemsBookFilters placeholders', () => {
beforeEach(async () => {
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
+ global.ServerSettings = { sortingIgnorePrefix: false }
await Database.buildModels()
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
@@ -54,11 +55,17 @@ describe('libraryItemsBookFilters placeholders', () => {
return { book, libraryItem }
}
- const createSeriesWithBook = async ({ seriesName, title, isPlaceholder }) => {
- const series = await Database.seriesModel.create({
- name: seriesName,
- libraryId: library.id
- })
+ const createSeriesWithBook = async ({ seriesName, seriesId, title, isPlaceholder }) => {
+ let series = null
+ if (seriesId) {
+ 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 })
await Database.bookSeriesModel.create({
seriesId: series.id,
@@ -68,11 +75,17 @@ describe('libraryItemsBookFilters placeholders', () => {
return { series, book, libraryItem }
}
- const createAuthorWithBook = async ({ authorName, title, isPlaceholder }) => {
- const author = await Database.authorModel.create({
- name: authorName,
- libraryId: library.id
- })
+ const createAuthorWithBook = async ({ authorName, authorId, title, isPlaceholder }) => {
+ let author = null
+ if (authorId) {
+ 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 })
await Database.bookAuthorModel.create({
authorId: author.id,
@@ -85,18 +98,7 @@ describe('libraryItemsBookFilters placeholders', () => {
await createBookWithItem({ title: 'Real Book', isPlaceholder: false })
await createBookWithItem({ title: 'Placeholder Book', isPlaceholder: true })
- const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(
- library.id,
- user,
- null,
- null,
- 'addedAt',
- true,
- false,
- [],
- 20,
- 0
- )
+ const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, null, null, 'addedAt', true, false, [], 20, 0)
expect(count).to.equal(1)
expect(libraryItems).to.have.length(1)
@@ -115,7 +117,7 @@ describe('libraryItemsBookFilters placeholders', () => {
it('includes placeholders when fetching items for a series', async () => {
const real = await createSeriesWithBook({ seriesName: 'Series A', title: 'Real 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)
@@ -127,7 +129,7 @@ describe('libraryItemsBookFilters placeholders', () => {
it('includes placeholders when fetching items for an author', async () => {
const real = await createAuthorWithBook({ authorName: 'Author A', title: 'Real 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)
@@ -135,4 +137,58 @@ describe('libraryItemsBookFilters placeholders', () => {
const titles = libraryItems.map((item) => item.media.title).sort()
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)
+ })
})