From 2a775d434abf1a9f8c7719a1f27d79a4c21936d2 Mon Sep 17 00:00:00 2001
From: Jens Bickel <235654990+JensBickel@users.noreply.github.com>
Date: Mon, 9 Feb 2026 17:19:20 +0100
Subject: [PATCH] feat(server): promote placeholders on scan and enforce
visibility rules across list/search/author-series contexts
---
client/pages/author/_id.vue | 24 +-
docs/controllers/AuthorController.yaml | 40 +++
docs/root.yaml | 2 +
server/controllers/AuthorController.js | 183 +++++++++++++
server/controllers/LibraryItemController.js | 67 ++++-
server/routers/ApiRouter.js | 1 +
server/scanner/LibraryScanner.js | 61 +++++
server/utils/queries/libraryFilters.js | 7 +-
server/utils/queries/libraryItemFilters.js | 25 +-
.../utils/queries/libraryItemsBookFilters.js | 7 +-
.../AuthorController.placeholders.test.js | 170 ++++++++++++
.../controllers/LibraryItemController.test.js | 142 ++++++++++
.../LibraryScanner.placeholders.test.js | 256 ++++++++++++++++++
.../libraryFilters.placeholders.test.js | 89 ++++++
.../libraryItemFilters.placeholders.test.js | 79 ++++++
...braryItemsBookFilters.placeholders.test.js | 56 +++-
16 files changed, 1190 insertions(+), 19 deletions(-)
create mode 100644 test/server/controllers/AuthorController.placeholders.test.js
create mode 100644 test/server/scanner/LibraryScanner.placeholders.test.js
create mode 100644 test/server/utils/queries/libraryFilters.placeholders.test.js
create mode 100644 test/server/utils/queries/libraryItemFilters.placeholders.test.js
diff --git a/client/pages/author/_id.vue b/client/pages/author/_id.vue
index 11b940acd..058933531 100644
--- a/client/pages/author/_id.vue
+++ b/client/pages/author/_id.vue
@@ -14,6 +14,8 @@
+
+ {{ $strings.ButtonAddPlaceholder }}
{{ $strings.LabelDescription }}
@@ -67,7 +69,8 @@ export default {
data() {
return {
isDescriptionClamped: false,
- showFullDescription: false
+ showFullDescription: false,
+ processingPlaceholder: false
}
},
computed: {
@@ -95,6 +98,25 @@ export default {
editAuthor() {
this.$store.commit('globals/showEditAuthorModal', this.author)
},
+ async createAuthorPlaceholder() {
+ if (!this.author?.id || this.processingPlaceholder) return
+ this.processingPlaceholder = true
+ const payload = {
+ title: this.$strings.LabelPlaceholderDefaultTitle
+ }
+
+ try {
+ const libraryItem = await this.$axios.$post(`/api/authors/${this.author.id}/placeholders`, payload)
+ this.$toast.success(this.$strings.ToastPlaceholderCreated)
+ this.$store.commit('setBookshelfBookIds', [])
+ this.$store.commit('showEditModal', libraryItem)
+ } catch (error) {
+ console.error('Failed to create author placeholder', error)
+ this.$toast.error(this.$strings.ToastPlaceholderCreateFailed)
+ } finally {
+ this.processingPlaceholder = false
+ }
+ },
authorUpdated(author) {
if (author.id === this.author.id) {
console.log('Author was updated', author)
diff --git a/docs/controllers/AuthorController.yaml b/docs/controllers/AuthorController.yaml
index acfc04488..78d5ff023 100644
--- a/docs/controllers/AuthorController.yaml
+++ b/docs/controllers/AuthorController.yaml
@@ -300,3 +300,43 @@ paths:
- $ref: '#/components/schemas/authorUpdated'
'404':
$ref: '#/components/responses/author404'
+ /api/authors/{id}/placeholders:
+ parameters:
+ - name: id
+ in: path
+ description: Author ID
+ required: true
+ schema:
+ $ref: '../objects/entities/Author.yaml#/components/schemas/authorId'
+ post:
+ operationId: createAuthorPlaceholder
+ summary: Create author placeholder
+ description: Create a placeholder book item for an author. If `folderId` is omitted, the server uses the library folder from an existing author item; if the author has no items, it falls back to the library's first folder.
+ tags:
+ - Authors
+ requestBody:
+ required: false
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ title:
+ description: The placeholder title to use.
+ type: string
+ folderId:
+ description: Optional library folder to create the placeholder in.
+ $ref: '../objects/Folder.yaml#/components/schemas/folderId'
+ responses:
+ '200':
+ description: createAuthorPlaceholder OK
+ content:
+ application/json:
+ schema:
+ $ref: '../objects/LibraryItem.yaml#/components/schemas/libraryItemMinified'
+ '400':
+ description: Bad request
+ '403':
+ description: Forbidden
+ '404':
+ $ref: '#/components/responses/author404'
diff --git a/docs/root.yaml b/docs/root.yaml
index d624a9d9c..c5602703a 100644
--- a/docs/root.yaml
+++ b/docs/root.yaml
@@ -21,6 +21,8 @@ paths:
$ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1image'
/api/authors/{id}/match:
$ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1match'
+ /api/authors/{id}/placeholders:
+ $ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1placeholders'
/api/emails/settings:
$ref: './controllers/EmailController.yaml#/paths/~1api~1emails~1settings'
/api/emails/test:
diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js
index 50eeda31a..0d99eb6a2 100644
--- a/server/controllers/AuthorController.js
+++ b/server/controllers/AuthorController.js
@@ -1,3 +1,4 @@
+const Path = require('path')
const { Request, Response, NextFunction } = require('express')
const sequelize = require('sequelize')
const fs = require('../libs/fsExtra')
@@ -9,6 +10,8 @@ const Database = require('../Database')
const CacheManager = require('../managers/CacheManager')
const CoverManager = require('../managers/CoverManager')
const AuthorFinder = require('../finders/AuthorFinder')
+const { getTitleIgnorePrefix } = require('../utils')
+const { sanitizeFilename, filePathToPOSIX } = require('../utils/fileUtils')
const { reqSupportsWebp, isValidASIN } = require('../utils/index')
@@ -256,6 +259,186 @@ class AuthorController {
res.sendStatus(200)
}
+ /**
+ * POST: /api/authors/:id/placeholders
+ *
+ * @param {AuthorControllerRequest} req
+ * @param {Response} res
+ */
+ async createPlaceholder(req, res) {
+ if (!req.user.canUpdate) {
+ Logger.warn(`[AuthorController] User "${req.user.username}" attempted to create placeholder without permission`)
+ return res.sendStatus(403)
+ }
+
+ if (req.body?.cover || req.body?.url || req.files?.cover) {
+ return res.status(400).send('Cover uploads are not supported for placeholders')
+ }
+
+ const library = await Database.libraryModel.findByIdWithFolders(req.author.libraryId)
+ if (!library) {
+ return res.status(404).send('Library not found')
+ }
+
+ if (!req.user.checkCanAccessLibrary(library.id)) {
+ Logger.warn(`[AuthorController] User "${req.user.username}" attempted to access library "${library.id}" without permission`)
+ return res.sendStatus(403)
+ }
+
+ if (library.mediaType !== 'book') {
+ return res.status(400).send('Library media type does not support author placeholders')
+ }
+
+ const requestedTitle = typeof req.body?.title === 'string' ? req.body.title.trim() : ''
+ const placeholderTitle = requestedTitle || 'Placeholder'
+ const requestedFolderId = typeof req.body?.folderId === 'string' ? req.body.folderId.trim() : ''
+
+ let libraryFolder = null
+ if (requestedFolderId) {
+ libraryFolder = library.libraryFolders?.find((folder) => folder.id === requestedFolderId)
+ if (!libraryFolder) {
+ return res.status(404).send('Folder not found')
+ }
+ } else {
+ const authorLibraryItem = await Database.libraryItemModel.findOne({
+ where: {
+ libraryId: library.id,
+ mediaType: 'book'
+ },
+ include: [
+ {
+ model: Database.bookModel,
+ required: true,
+ include: [
+ {
+ model: Database.authorModel,
+ required: true,
+ where: {
+ id: req.author.id
+ },
+ through: {
+ attributes: []
+ }
+ }
+ ]
+ }
+ ],
+ order: [['createdAt', 'DESC']]
+ })
+
+ if (authorLibraryItem?.libraryFolderId) {
+ libraryFolder = library.libraryFolders?.find((folder) => folder.id === authorLibraryItem.libraryFolderId) || null
+ }
+ }
+
+ if (!libraryFolder) {
+ libraryFolder = library.libraryFolders?.[0] || null
+ }
+
+ if (!libraryFolder) {
+ Logger.error(`[AuthorController] Library "${library.id}" has no folders for placeholder creation`)
+ return res.status(400).send('Library has no folders')
+ }
+
+ const outputDirectoryParts = [req.author.name, placeholderTitle]
+ const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map((part) => sanitizeFilename(part))
+ const outputDirectory = filePathToPOSIX(Path.join(...[libraryFolder.path, ...cleanedOutputDirectoryParts]))
+
+ const existingLibraryItemCount = await Database.libraryItemModel.count({
+ where: {
+ path: outputDirectory
+ }
+ })
+ if (existingLibraryItemCount) {
+ return res.status(400).send('Library item already exists at that path')
+ }
+
+ try {
+ await fs.ensureDir(outputDirectory)
+ } catch (error) {
+ Logger.error(`[AuthorController] Failed to create placeholder directory "${outputDirectory}"`, error)
+ return res.status(500).send('Failed to create placeholder directory')
+ }
+
+ let relPath = outputDirectory.replace(filePathToPOSIX(libraryFolder.path), '')
+ if (relPath.startsWith('/')) relPath = relPath.slice(1)
+
+ const bookPayload = {
+ title: placeholderTitle,
+ titleIgnorePrefix: getTitleIgnorePrefix(placeholderTitle),
+ subtitle: null,
+ publishedYear: null,
+ publishedDate: null,
+ publisher: null,
+ description: null,
+ isbn: null,
+ asin: null,
+ language: null,
+ explicit: false,
+ abridged: false,
+ coverPath: null,
+ duration: 0,
+ narrators: [],
+ audioFiles: [],
+ ebookFile: null,
+ chapters: [],
+ tags: [],
+ genres: []
+ }
+
+ let newLibraryItem = null
+ const transaction = await Database.sequelize.transaction()
+ try {
+ const book = await Database.bookModel.create(bookPayload, { transaction })
+
+ await Database.bookAuthorModel.create(
+ {
+ bookId: book.id,
+ authorId: req.author.id
+ },
+ { transaction }
+ )
+
+ newLibraryItem = await Database.libraryItemModel.create(
+ {
+ ino: null,
+ path: outputDirectory,
+ relPath,
+ mediaId: book.id,
+ mediaType: 'book',
+ isFile: false,
+ isMissing: false,
+ isInvalid: false,
+ isPlaceholder: true,
+ mtime: 0,
+ ctime: 0,
+ birthtime: 0,
+ size: 0,
+ libraryFiles: [],
+ extraData: {},
+ libraryId: library.id,
+ libraryFolderId: libraryFolder.id,
+ title: placeholderTitle,
+ titleIgnorePrefix: getTitleIgnorePrefix(placeholderTitle),
+ authorNamesFirstLast: req.author.name,
+ authorNamesLastFirst: req.author.lastFirst || ''
+ },
+ { transaction }
+ )
+
+ await transaction.commit()
+ } catch (error) {
+ Logger.error('[AuthorController] Failed to create author placeholder', error)
+ await transaction.rollback()
+ return res.status(500).send('Failed to create placeholder')
+ }
+
+ newLibraryItem.media = await newLibraryItem.getMediaExpanded()
+ SocketAuthority.libraryItemEmitter('item_added', newLibraryItem)
+
+ res.json(newLibraryItem.toOldJSONExpanded())
+ }
+
/**
* POST: /api/authors/:id/image
* Upload author image from web URL
diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js
index 354fba56e..109b33915 100644
--- a/server/controllers/LibraryItemController.js
+++ b/server/controllers/LibraryItemController.js
@@ -193,6 +193,43 @@ class LibraryItemController {
const originalPath = req.libraryItem.path
const wasPlaceholder = !!req.libraryItem.isPlaceholder
+ const placeholderAuthorDirectory = (() => {
+ const authorNamesFirstLast = typeof req.libraryItem.authorNamesFirstLast === 'string' ? req.libraryItem.authorNamesFirstLast.trim() : ''
+ if (!authorNamesFirstLast) return ''
+ return sanitizeFilename(authorNamesFirstLast)
+ })()
+
+ if (wasPlaceholder && !req.libraryItem.isFile) {
+ const nextTitleRaw = typeof mediaPayload?.metadata?.title === 'string' ? mediaPayload.metadata.title : req.libraryItem.media?.title || ''
+ const nextTitle = sanitizeFilename(nextTitleRaw)
+ if (!nextTitle) {
+ return res.status(400).send('Invalid placeholder title')
+ }
+
+ let nextSeriesName = null
+ if (Array.isArray(mediaPayload?.metadata?.series) && mediaPayload.metadata.series.length) {
+ nextSeriesName = mediaPayload.metadata.series[0]?.name || null
+ } else if (Array.isArray(req.libraryItem.media?.series) && req.libraryItem.media.series.length) {
+ nextSeriesName = req.libraryItem.media.series[0]?.name || null
+ }
+
+ const libraryFolder = await Database.libraryFolderModel.findByPk(req.libraryItem.libraryFolderId)
+ if (libraryFolder?.path) {
+ const nextRelPathParts = []
+ if (placeholderAuthorDirectory) nextRelPathParts.push(placeholderAuthorDirectory)
+ if (nextSeriesName) {
+ const sanitizedNextSeriesName = sanitizeFilename(nextSeriesName)
+ if (sanitizedNextSeriesName) nextRelPathParts.push(sanitizedNextSeriesName)
+ }
+ nextRelPathParts.push(nextTitle)
+
+ const nextPath = filePathToPOSIX(Path.join(libraryFolder.path, ...nextRelPathParts))
+ if (nextPath !== originalPath && (await fs.pathExists(nextPath))) {
+ return res.status(400).send('Library item already exists at that path')
+ }
+ }
+ }
+
if (mediaPayload.url) {
await LibraryItemController.prototype.uploadCover.bind(this)(req, res, false)
if (res.writableEnded || res.headersSent) return
@@ -246,18 +283,33 @@ class LibraryItemController {
}
}
- if (
- wasPlaceholder &&
- typeof req.libraryItem.media?.title === 'string' &&
- req.libraryItem.media.title &&
- req.libraryItem.media.title !== originalTitle
- ) {
+ const placeholderTitleUpdated = wasPlaceholder && typeof req.libraryItem.media?.title === 'string' && req.libraryItem.media.title && req.libraryItem.media.title !== originalTitle
+ const placeholderSeriesUpdated = wasPlaceholder && req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)
+
+ if ((placeholderTitleUpdated || placeholderSeriesUpdated) && !req.libraryItem.isFile) {
const sanitizedTitle = sanitizeFilename(req.libraryItem.media.title)
if (!sanitizedTitle) {
return res.status(400).send('Invalid placeholder title')
}
- const updatedPath = filePathToPOSIX(Path.join(Path.dirname(originalPath), sanitizedTitle))
+ const libraryFolder = await Database.libraryFolderModel.findByPk(req.libraryItem.libraryFolderId)
+
+ let updatedSeriesName = null
+ if (Array.isArray(req.libraryItem.media?.series) && req.libraryItem.media.series.length) {
+ updatedSeriesName = req.libraryItem.media.series[0]?.name || null
+ } else if (Array.isArray(mediaPayload.metadata?.series) && mediaPayload.metadata.series.length) {
+ updatedSeriesName = mediaPayload.metadata.series[0]?.name || null
+ }
+
+ const nextRelPathParts = []
+ if (placeholderAuthorDirectory) nextRelPathParts.push(placeholderAuthorDirectory)
+ if (updatedSeriesName) {
+ const sanitizedUpdatedSeriesName = sanitizeFilename(updatedSeriesName)
+ if (sanitizedUpdatedSeriesName) nextRelPathParts.push(sanitizedUpdatedSeriesName)
+ }
+ nextRelPathParts.push(sanitizedTitle)
+
+ const updatedPath = libraryFolder?.path ? filePathToPOSIX(Path.join(libraryFolder.path, ...nextRelPathParts)) : filePathToPOSIX(Path.join(Path.dirname(originalPath), sanitizedTitle))
if (updatedPath !== originalPath) {
if (await fs.pathExists(updatedPath)) {
return res.status(400).send('Library item already exists at that path')
@@ -280,7 +332,6 @@ class LibraryItemController {
}
}
- const libraryFolder = await Database.libraryFolderModel.findByPk(req.libraryItem.libraryFolderId)
if (libraryFolder?.path) {
let relPath = updatedPath.replace(filePathToPOSIX(libraryFolder.path), '')
if (relPath.startsWith('/')) relPath = relPath.slice(1)
diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js
index 562d5f39c..d592f8dd9 100644
--- a/server/routers/ApiRouter.js
+++ b/server/routers/ApiRouter.js
@@ -211,6 +211,7 @@ class ApiRouter {
// Author Routes
//
this.router.get('/authors/:id', AuthorController.middleware.bind(this), AuthorController.findOne.bind(this))
+ this.router.post('/authors/:id/placeholders', AuthorController.middleware.bind(this), AuthorController.createPlaceholder.bind(this))
this.router.patch('/authors/:id', AuthorController.middleware.bind(this), AuthorController.update.bind(this))
this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this))
this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this))
diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js
index 640c82d76..1e73b94ca 100644
--- a/server/scanner/LibraryScanner.js
+++ b/server/scanner/LibraryScanner.js
@@ -183,6 +183,8 @@ class LibraryScanner {
// Podcast folder can have no episodes and still be valid
if (libraryScan.libraryMediaType === 'podcast' && (await fs.pathExists(existingLibraryItem.path))) {
libraryScan.addLog(LogLevel.INFO, `Library item "${existingLibraryItem.relPath}" folder exists but has no episodes`)
+ } else if (existingLibraryItem.isPlaceholder) {
+ libraryScan.addLog(LogLevel.DEBUG, `Placeholder library item "${existingLibraryItem.relPath}" was not found during scan - skipping missing check`)
} else {
libraryScan.addLog(LogLevel.WARN, `Library Item "${existingLibraryItem.path}" (inode: ${existingLibraryItem.ino}) is missing`)
libraryScan.resultsMissing++
@@ -201,6 +203,21 @@ class LibraryScanner {
} else {
libraryItemDataFound = libraryItemDataFound.filter((lidf) => lidf !== libraryItemData)
let libraryItemDataUpdated = await libraryItemData.checkLibraryItemData(existingLibraryItem, libraryScan)
+ const placeholderHasMedia = existingLibraryItem.isPlaceholder && (libraryItemData.audioLibraryFiles.length || libraryItemData.ebookLibraryFiles.length)
+ if (existingLibraryItem.isPlaceholder && !placeholderHasMedia) {
+ libraryScan.addLog(LogLevel.DEBUG, `Placeholder library item "${existingLibraryItem.relPath}" has no media files - skipping rescan`)
+ if (libraryItemDataUpdated) {
+ libraryScan.resultsUpdated++
+ const libraryItem = await Database.libraryItemModel.getExpandedById(existingLibraryItem.id)
+ if (libraryItem) {
+ libraryItemsUpdated.push(libraryItem)
+ }
+ }
+ continue
+ }
+ if (placeholderHasMedia) {
+ await this.promotePlaceholder(existingLibraryItem, libraryScan)
+ }
if (libraryItemDataUpdated || forceRescan) {
if (forceRescan || libraryItemData.hasLibraryFileChanges || libraryItemData.hasPathChange) {
const { libraryItem, wasUpdated } = await LibraryItemScanner.rescanLibraryItemMedia(existingLibraryItem, libraryItemData, libraryScan.library.settings, libraryScan)
@@ -590,6 +607,11 @@ class LibraryScanner {
if (existingLibraryItem.path === fullPath) {
const exists = await fs.pathExists(fullPath)
if (!exists) {
+ if (existingLibraryItem.isPlaceholder) {
+ Logger.info(`[LibraryScanner] Scanning file update group and placeholder library item was deleted "${existingLibraryItem.media.title}" - ignoring missing check`)
+ itemGroupingResults[itemDir] = ScanResult.NOTHING
+ continue
+ }
Logger.info(`[LibraryScanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.title}" - marking as missing`)
existingLibraryItem.isMissing = true
await existingLibraryItem.save()
@@ -599,6 +621,15 @@ class LibraryScanner {
continue
}
}
+ if (existingLibraryItem.isPlaceholder) {
+ const placeholderHasAudio = hasAudioFiles(fileUpdateGroup, itemDir)
+ if (!placeholderHasAudio) {
+ Logger.debug(`[LibraryScanner] Placeholder library item "${existingLibraryItem.relPath}" has no audio file updates - skipping scan`)
+ itemGroupingResults[itemDir] = ScanResult.NOTHING
+ continue
+ }
+ await this.promotePlaceholder(existingLibraryItem)
+ }
// Scan library item for updates
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.title}" with id "${existingLibraryItem.id}" - scan for updates`)
itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, updatedLibraryItemDetails)
@@ -639,6 +670,36 @@ class LibraryScanner {
return itemGroupingResults
}
+
+ /**
+ * Promote a placeholder item when media files are detected.
+ * @param {import('../models/LibraryItem')} existingLibraryItem
+ * @param {import('./LibraryScan')} [libraryScan]
+ * @returns {Promise}
+ */
+ async promotePlaceholder(existingLibraryItem, libraryScan = null) {
+ if (!existingLibraryItem?.isPlaceholder) return false
+
+ existingLibraryItem.isPlaceholder = false
+ if (existingLibraryItem.isMissing) {
+ existingLibraryItem.isMissing = false
+ }
+
+ if (existingLibraryItem.changed) {
+ existingLibraryItem.changed('isPlaceholder', true)
+ if (existingLibraryItem.isMissing === false) {
+ existingLibraryItem.changed('isMissing', true)
+ }
+ }
+
+ await existingLibraryItem.save()
+
+ if (libraryScan) {
+ libraryScan.addLog(LogLevel.INFO, `Promoted placeholder library item "${existingLibraryItem.relPath}" to a real item`)
+ }
+
+ return true
+ }
}
module.exports = new LibraryScanner()
diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js
index 7312b9d5d..cf7e3dbf0 100644
--- a/server/utils/queries/libraryFilters.js
+++ b/server/utils/queries/libraryFilters.js
@@ -553,7 +553,8 @@ module.exports = {
model: Database.libraryItemModel,
attributes: [],
where: {
- libraryId: libraryId
+ libraryId: libraryId,
+ isPlaceholder: false
}
}
})
@@ -582,6 +583,7 @@ module.exports = {
attributes: [],
where: {
libraryId: libraryId,
+ isPlaceholder: false,
updatedAt: {
[Sequelize.Op.gt]: new Date(lastLoadedAt)
}
@@ -636,7 +638,8 @@ module.exports = {
model: Database.libraryItemModel,
attributes: ['isMissing', 'isInvalid'],
where: {
- libraryId: libraryId
+ libraryId: libraryId,
+ isPlaceholder: false
}
},
attributes: ['tags', 'genres', 'publisher', 'publishedYear', 'narrators', 'language']
diff --git a/server/utils/queries/libraryItemFilters.js b/server/utils/queries/libraryItemFilters.js
index 7f95d0ecc..eb1b1282c 100644
--- a/server/utils/queries/libraryItemFilters.js
+++ b/server/utils/queries/libraryItemFilters.js
@@ -20,7 +20,10 @@ module.exports = {
},
include: [
{
- model: Database.libraryItemModel
+ model: Database.libraryItemModel,
+ where: {
+ isPlaceholder: false
+ }
},
{
model: Database.authorModel,
@@ -54,7 +57,10 @@ module.exports = {
},
include: [
{
- model: Database.libraryItemModel
+ model: Database.libraryItemModel,
+ where: {
+ isPlaceholder: false
+ }
},
{
model: Database.podcastEpisodeModel
@@ -85,7 +91,10 @@ module.exports = {
},
include: [
{
- model: Database.libraryItemModel
+ model: Database.libraryItemModel,
+ where: {
+ isPlaceholder: false
+ }
},
{
model: Database.authorModel,
@@ -115,7 +124,10 @@ module.exports = {
},
include: [
{
- model: Database.libraryItemModel
+ model: Database.libraryItemModel,
+ where: {
+ isPlaceholder: false
+ }
},
{
model: Database.podcastEpisodeModel
@@ -146,7 +158,10 @@ module.exports = {
},
include: [
{
- model: Database.libraryItemModel
+ model: Database.libraryItemModel,
+ where: {
+ isPlaceholder: false
+ }
},
{
model: Database.authorModel,
diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js
index 7ae1dc866..9ff9f751a 100644
--- a/server/utils/queries/libraryItemsBookFilters.js
+++ b/server/utils/queries/libraryItemsBookFilters.js
@@ -408,8 +408,10 @@ module.exports = {
let bookAttributes = null
+ const allowPlaceholderItems = filterGroup === 'series' || filterGroup === 'authors'
const libraryItemWhere = {
- libraryId
+ libraryId,
+ ...(allowPlaceholderItems ? {} : { isPlaceholder: false })
}
let seriesInclude = {
@@ -1079,7 +1081,8 @@ module.exports = {
{
model: Database.libraryItemModel,
where: {
- libraryId: library.id
+ libraryId: library.id,
+ isPlaceholder: false
}
},
{
diff --git a/test/server/controllers/AuthorController.placeholders.test.js b/test/server/controllers/AuthorController.placeholders.test.js
new file mode 100644
index 000000000..a2ef915d2
--- /dev/null
+++ b/test/server/controllers/AuthorController.placeholders.test.js
@@ -0,0 +1,170 @@
+const { expect } = require('chai')
+const { Sequelize } = require('sequelize')
+const sinon = require('sinon')
+
+const Database = require('../../../server/Database')
+const ApiRouter = require('../../../server/routers/ApiRouter')
+const AuthorController = require('../../../server/controllers/AuthorController')
+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('AuthorController placeholders', () => {
+ /** @type {ApiRouter} */
+ let apiRouter
+ let library
+ let libraryFolder
+ let secondaryLibraryFolder
+ let author
+ let user
+
+ beforeEach(async () => {
+ global.ServerSettings = {}
+ Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
+ Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
+ await Database.buildModels()
+
+ apiRouter = new ApiRouter({
+ auth: new Auth(),
+ apiCacheManager: new ApiCacheManager()
+ })
+
+ 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 })
+ secondaryLibraryFolder = await Database.libraryFolderModel.create({ path: '/secondary', libraryId: library.id })
+ author = await Database.authorModel.create({ name: 'Jane Doe', lastFirst: 'Doe, Jane', libraryId: library.id })
+
+ user = await Database.userModel.create({
+ username: 'Admin',
+ type: 'admin',
+ isActive: true,
+ permissions: User.getDefaultPermissionsForUserType('admin'),
+ bookmarks: [],
+ extraData: { seriesHideFromContinueListening: [] }
+ })
+ })
+
+ afterEach(async () => {
+ sinon.restore()
+ await Database.sequelize.sync({ force: true })
+ })
+
+ it('creates placeholder items for an author', async () => {
+ const fakeReq = {
+ params: {
+ id: author.id
+ },
+ author,
+ user,
+ body: {}
+ }
+
+ const fakeRes = {
+ status: sinon.stub().returnsThis(),
+ json: sinon.spy(),
+ sendStatus: sinon.spy()
+ }
+
+ await AuthorController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
+
+ expect(fakeRes.json.calledOnce).to.be.true
+ const payload = fakeRes.json.firstCall.args[0]
+ expect(payload).to.include({ mediaType: 'book', isPlaceholder: true })
+ expect(payload.media.metadata.authors.some((entry) => entry.id === author.id)).to.be.true
+ })
+
+ it('creates author placeholder directories on disk', async () => {
+ const fakeReq = {
+ params: {
+ id: author.id
+ },
+ author,
+ user,
+ body: {}
+ }
+
+ const fakeRes = {
+ status: sinon.stub().returnsThis(),
+ json: sinon.spy(),
+ sendStatus: sinon.spy(),
+ send: sinon.spy()
+ }
+
+ await AuthorController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
+
+ expect(fs.ensureDir.calledOnce).to.be.true
+ expect(fs.ensureDir.firstCall.args[0]).to.equal('/test/Jane Doe/Placeholder')
+ })
+
+ it('rejects cover url payloads for author placeholder creation', async () => {
+ const fakeReq = {
+ params: {
+ id: author.id
+ },
+ author,
+ user,
+ body: {
+ url: 'http://example.com/cover.jpg'
+ }
+ }
+
+ const fakeRes = {
+ status: sinon.stub().returnsThis(),
+ json: sinon.spy(),
+ send: sinon.spy(),
+ sendStatus: sinon.spy()
+ }
+
+ await AuthorController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
+
+ expect(fakeRes.status.calledWith(400)).to.be.true
+ expect(fakeRes.send.calledWith('Cover uploads are not supported for placeholders')).to.be.true
+ })
+
+ it('uses the authors library folder when creating a placeholder', async () => {
+ const existingBook = await Database.bookModel.create({
+ title: 'Existing Book',
+ audioFiles: [],
+ tags: [],
+ narrators: [],
+ genres: [],
+ chapters: []
+ })
+ await Database.bookAuthorModel.create({ bookId: existingBook.id, authorId: author.id })
+ await Database.libraryItemModel.create({
+ libraryFiles: [],
+ mediaId: existingBook.id,
+ mediaType: 'book',
+ libraryId: library.id,
+ libraryFolderId: secondaryLibraryFolder.id
+ })
+
+ const fakeReq = {
+ params: {
+ id: author.id
+ },
+ author,
+ user,
+ body: {}
+ }
+
+ const fakeRes = {
+ status: sinon.stub().returnsThis(),
+ json: sinon.spy(),
+ sendStatus: sinon.spy(),
+ send: sinon.spy()
+ }
+
+ await AuthorController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
+
+ expect(fakeRes.json.calledOnce).to.be.true
+ const payload = fakeRes.json.firstCall.args[0]
+ expect(payload.folderId).to.equal(secondaryLibraryFolder.id)
+ expect(payload.path.startsWith(secondaryLibraryFolder.path)).to.be.true
+ })
+})
diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js
index bca6dd766..64ad02395 100644
--- a/test/server/controllers/LibraryItemController.test.js
+++ b/test/server/controllers/LibraryItemController.test.js
@@ -216,6 +216,7 @@ describe('LibraryItemController', () => {
tempPaths.push(tempRoot)
const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id })
+ const series = await Database.seriesModel.create({ name: 'Test Series', libraryId: library.id })
const oldTitle = 'Old Title'
const newTitle = 'New Title'
const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series'))
@@ -223,6 +224,7 @@ describe('LibraryItemController', () => {
await fs.ensureDir(oldPath)
const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
+ await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id })
const libraryItem = await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: book.id,
@@ -321,6 +323,7 @@ describe('LibraryItemController', () => {
tempPaths.push(tempRoot)
const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id })
+ const series = await Database.seriesModel.create({ name: 'Test Series', libraryId: library.id })
const oldTitle = 'Old Title'
const newTitle = 'New Title'
const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series'))
@@ -330,6 +333,7 @@ describe('LibraryItemController', () => {
await fs.ensureDir(newPath)
const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
+ await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id })
const libraryItem = await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: book.id,
@@ -364,6 +368,13 @@ describe('LibraryItemController', () => {
expect(fakeRes.status.calledWith(400)).to.be.true
expect(fakeRes.send.calledWith('Library item already exists at that path')).to.be.true
+
+ const updatedItem = await Database.libraryItemModel.findByPk(libraryItem.id)
+ expect(updatedItem.path).to.equal(oldPath)
+ expect(updatedItem.relPath).to.equal(`Test Series/${oldTitle}`)
+
+ const updatedBook = await Database.bookModel.findByPk(book.id)
+ expect(updatedBook.title).to.equal(oldTitle)
})
it('creates placeholder folder when metadata is stored with items and original path is missing', async () => {
@@ -373,12 +384,14 @@ describe('LibraryItemController', () => {
tempPaths.push(tempRoot)
const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id })
+ const series = await Database.seriesModel.create({ name: 'Test Series', libraryId: library.id })
const oldTitle = 'Old Title'
const newTitle = 'New Title'
const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series'))
const oldPath = filePathToPOSIX(Path.join(seriesDir, oldTitle))
const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
+ await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id })
const libraryItem = await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: book.id,
@@ -415,5 +428,134 @@ describe('LibraryItemController', () => {
const newPathExists = await fs.pathExists(expectedPath)
expect(newPathExists).to.be.true
})
+
+ it('moves placeholder folder when series changes', async () => {
+ const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
+ const tempRoot = Path.join('/tmp', `abs-placeholder-${Date.now()}`)
+ tempPaths.push(tempRoot)
+
+ const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id })
+ const oldSeries = await Database.seriesModel.create({ name: 'Series A', libraryId: library.id })
+ const newSeries = await Database.seriesModel.create({ name: 'Series B', libraryId: library.id })
+
+ const title = 'Placeholder'
+ const oldPath = filePathToPOSIX(Path.join(libraryFolder.path, 'Author A', oldSeries.name, title))
+ await fs.ensureDir(oldPath)
+
+ const book = await Database.bookModel.create({ title, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
+ await Database.bookSeriesModel.create({ bookId: book.id, seriesId: oldSeries.id, sequence: '1' })
+
+ const libraryItem = await Database.libraryItemModel.create({
+ libraryFiles: [],
+ mediaId: book.id,
+ mediaType: 'book',
+ libraryId: library.id,
+ libraryFolderId: libraryFolder.id,
+ path: oldPath,
+ relPath: `Author A/${oldSeries.name}/${title}`,
+ authorNamesFirstLast: 'Author A',
+ authorNamesLastFirst: 'A, Author',
+ isFile: false,
+ isPlaceholder: true
+ })
+
+ const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id)
+ expandedItem.saveMetadataFile = sinon.stub().resolves()
+
+ const fakeReq = {
+ query: {},
+ body: {
+ metadata: {
+ series: [{ id: newSeries.id, name: newSeries.name }]
+ }
+ },
+ libraryItem: expandedItem
+ }
+ const fakeRes = {
+ json: sinon.spy(),
+ status: sinon.stub().returnsThis(),
+ send: sinon.spy()
+ }
+
+ await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes)
+
+ expect(fakeRes.json.calledOnce).to.be.true
+
+ const expectedPath = filePathToPOSIX(Path.join(libraryFolder.path, 'Author A', newSeries.name, title))
+ const oldPathExists = await fs.pathExists(oldPath)
+ const newPathExists = await fs.pathExists(expectedPath)
+ expect(oldPathExists).to.be.false
+ expect(newPathExists).to.be.true
+
+ const updatedItem = await Database.libraryItemModel.findByPk(libraryItem.id)
+ expect(updatedItem.path).to.equal(expectedPath)
+ expect(updatedItem.relPath).to.equal(`Author A/${newSeries.name}/${title}`)
+ })
+
+ it('moves series-origin placeholder folder without inferring author directory from relPath', async () => {
+ const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
+ const tempRoot = Path.join('/tmp', `abs-placeholder-${Date.now()}`)
+ tempPaths.push(tempRoot)
+
+ const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id })
+ const oldSeries = await Database.seriesModel.create({ name: 'Series A', libraryId: library.id })
+ const newSeries = await Database.seriesModel.create({ name: 'Series B', libraryId: library.id })
+
+ const title = 'Placeholder'
+ const oldPath = filePathToPOSIX(Path.join(libraryFolder.path, oldSeries.name, title))
+ await fs.ensureDir(oldPath)
+
+ const book = await Database.bookModel.create({ title, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
+ await Database.bookSeriesModel.create({ bookId: book.id, seriesId: oldSeries.id, sequence: '1' })
+
+ const libraryItem = await Database.libraryItemModel.create({
+ libraryFiles: [],
+ mediaId: book.id,
+ mediaType: 'book',
+ libraryId: library.id,
+ libraryFolderId: libraryFolder.id,
+ path: oldPath,
+ relPath: `${oldSeries.name}/${title}`,
+ authorNamesFirstLast: '',
+ authorNamesLastFirst: '',
+ isFile: false,
+ isPlaceholder: true
+ })
+
+ const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id)
+ expandedItem.saveMetadataFile = sinon.stub().resolves()
+
+ const fakeReq = {
+ query: {},
+ body: {
+ metadata: {
+ series: [{ id: newSeries.id, name: newSeries.name }]
+ }
+ },
+ libraryItem: expandedItem
+ }
+ const fakeRes = {
+ json: sinon.spy(),
+ status: sinon.stub().returnsThis(),
+ send: sinon.spy()
+ }
+
+ await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes)
+
+ expect(fakeRes.json.calledOnce).to.be.true
+
+ const expectedPath = filePathToPOSIX(Path.join(libraryFolder.path, newSeries.name, title))
+ const invalidPath = filePathToPOSIX(Path.join(libraryFolder.path, oldSeries.name, newSeries.name, title))
+ const oldPathExists = await fs.pathExists(oldPath)
+ const newPathExists = await fs.pathExists(expectedPath)
+ const invalidPathExists = await fs.pathExists(invalidPath)
+ expect(oldPathExists).to.be.false
+ expect(newPathExists).to.be.true
+ expect(invalidPathExists).to.be.false
+
+ const updatedItem = await Database.libraryItemModel.findByPk(libraryItem.id)
+ expect(updatedItem.path).to.equal(expectedPath)
+ expect(updatedItem.relPath).to.equal(`${newSeries.name}/${title}`)
+ })
})
})
diff --git a/test/server/scanner/LibraryScanner.placeholders.test.js b/test/server/scanner/LibraryScanner.placeholders.test.js
new file mode 100644
index 000000000..3d604fd3c
--- /dev/null
+++ b/test/server/scanner/LibraryScanner.placeholders.test.js
@@ -0,0 +1,256 @@
+const chai = require('chai')
+const sinon = require('sinon')
+
+const LibraryScanner = require('../../../server/scanner/LibraryScanner')
+const LibraryScan = require('../../../server/scanner/LibraryScan')
+const LibraryItemScanner = require('../../../server/scanner/LibraryItemScanner')
+const Database = require('../../../server/Database')
+const libraryFilters = require('../../../server/utils/queries/libraryFilters')
+const SocketAuthority = require('../../../server/SocketAuthority')
+const fs = require('../../../server/libs/fsExtra')
+const { ScanResult } = require('../../../server/utils/constants')
+
+const expect = chai.expect
+
+describe('LibraryScanner placeholder handling', () => {
+ let originalSequelize
+
+ beforeEach(() => {
+ originalSequelize = Database.sequelize
+ Database.sequelize = {
+ models: {
+ libraryItem: {
+ findAll: () => {},
+ getExpandedById: () => {},
+ update: () => {},
+ findOneExpanded: () => {},
+ findOne: () => {}
+ }
+ }
+ }
+ })
+
+ afterEach(() => {
+ Database.sequelize = originalSequelize
+ sinon.restore()
+ })
+
+ it('skips missing checks for placeholder items during full scans', async () => {
+ const library = {
+ id: 'library-1',
+ name: 'Test Library',
+ mediaType: 'book',
+ settings: {},
+ libraryFolders: [
+ {
+ id: 'folder-1',
+ path: '/library'
+ }
+ ]
+ }
+
+ const libraryScan = new LibraryScan()
+ libraryScan.setData(library)
+
+ const placeholderItem = {
+ id: 'item-1',
+ path: '/library/Series/Placeholder',
+ relPath: 'Series/Placeholder',
+ ino: 123,
+ isPlaceholder: true,
+ isMissing: false,
+ libraryFiles: [],
+ save: sinon.stub().resolves(),
+ changed: sinon.stub()
+ }
+
+ sinon.stub(libraryFilters, 'getFilterData').resolves()
+ sinon.stub(LibraryScanner, 'scanFolder').resolves([])
+ const findAllStub = sinon.stub(Database.libraryItemModel, 'findAll').resolves([placeholderItem])
+ const getExpandedStub = sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null)
+ const updateStub = sinon.stub(Database.libraryItemModel, 'update').resolves()
+ const checkAuthorsStub = sinon.stub(LibraryItemScanner, 'checkAuthorsAndSeriesRemovedFromBooks').resolves()
+
+ await LibraryScanner.scanLibrary(libraryScan, false)
+
+ expect(findAllStub.calledOnce).to.be.true
+ expect(getExpandedStub.called).to.be.false
+ expect(updateStub.called).to.be.false
+ expect(checkAuthorsStub.calledOnce).to.be.true
+ expect(libraryScan.resultsMissing).to.equal(0)
+ })
+
+ it('promotes placeholders when audio files are detected in folder updates', async () => {
+ const library = {
+ id: 'library-1',
+ name: 'Test Library',
+ mediaType: 'book',
+ settings: {}
+ }
+ const folder = {
+ id: 'folder-1',
+ path: '/library',
+ libraryId: library.id
+ }
+
+ const placeholderItem = {
+ id: 'item-1',
+ path: '/library/Series/Placeholder',
+ relPath: 'Series/Placeholder',
+ isPlaceholder: true,
+ isMissing: false,
+ media: { title: 'Placeholder Book' },
+ save: sinon.stub().resolves(),
+ changed: sinon.stub()
+ }
+
+ sinon.stub(libraryFilters, 'getFilterData').resolves()
+ sinon.stub(Database.libraryItemModel, 'findOneExpanded').resolves(placeholderItem)
+ sinon.stub(Database.libraryItemModel, 'findOne').resolves(null)
+ sinon.stub(fs, 'pathExists').resolves(true)
+ const scanLibraryItemStub = sinon.stub(LibraryItemScanner, 'scanLibraryItem').resolves(ScanResult.UPDATED)
+ sinon.stub(SocketAuthority, 'libraryItemEmitter')
+
+ const fileUpdateGroup = {
+ 'Series/Placeholder': ['track-01.mp3']
+ }
+
+ const results = await LibraryScanner.scanFolderUpdates(library, folder, fileUpdateGroup)
+
+ expect(placeholderItem.isPlaceholder).to.be.false
+ expect(placeholderItem.save.calledOnce).to.be.true
+ expect(scanLibraryItemStub.calledOnce).to.be.true
+ expect(results['Series/Placeholder']).to.equal(ScanResult.UPDATED)
+ })
+
+ it('promotes placeholders during full scans when media is present', async () => {
+ const library = {
+ id: 'library-1',
+ name: 'Test Library',
+ mediaType: 'book',
+ settings: {},
+ libraryFolders: [
+ {
+ id: 'folder-1',
+ path: '/library'
+ }
+ ]
+ }
+
+ const libraryScan = new LibraryScan()
+ libraryScan.setData(library)
+
+ const placeholderItem = {
+ id: 'item-1',
+ path: '/library/Series/Placeholder',
+ relPath: 'Series/Placeholder',
+ isPlaceholder: true,
+ isMissing: false,
+ save: sinon.stub().resolves(),
+ changed: sinon.stub()
+ }
+
+ const libraryItemData = {
+ path: placeholderItem.path,
+ audioLibraryFiles: [{ metadata: { ext: '.mp3' } }],
+ ebookLibraryFiles: [],
+ hasLibraryFileChanges: true,
+ hasPathChange: false,
+ checkLibraryItemData: sinon.stub().resolves(true)
+ }
+
+ 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 })
+ sinon.stub(LibraryItemScanner, 'checkAuthorsAndSeriesRemovedFromBooks').resolves()
+ sinon.stub(SocketAuthority, 'libraryItemsEmitter')
+
+ await LibraryScanner.scanLibrary(libraryScan, false)
+
+ expect(placeholderItem.isPlaceholder).to.be.false
+ expect(placeholderItem.save.calledOnce).to.be.true
+ })
+
+ it('skips placeholder scans on file updates without audio', async () => {
+ const library = {
+ id: 'library-1',
+ name: 'Test Library',
+ mediaType: 'book',
+ settings: {}
+ }
+ const folder = {
+ id: 'folder-1',
+ path: '/library',
+ libraryId: library.id
+ }
+
+ const placeholderItem = {
+ id: 'item-1',
+ path: '/library/Series/Placeholder',
+ relPath: 'Series/Placeholder',
+ isPlaceholder: true,
+ isMissing: false,
+ media: { title: 'Placeholder Book' },
+ save: sinon.stub().resolves(),
+ changed: sinon.stub()
+ }
+
+ sinon.stub(libraryFilters, 'getFilterData').resolves()
+ sinon.stub(Database.libraryItemModel, 'findOneExpanded').resolves(placeholderItem)
+ sinon.stub(Database.libraryItemModel, 'findOne').resolves(null)
+ sinon.stub(fs, 'pathExists').resolves(true)
+ const scanLibraryItemStub = sinon.stub(LibraryItemScanner, 'scanLibraryItem').resolves(ScanResult.UPDATED)
+ sinon.stub(SocketAuthority, 'libraryItemEmitter')
+
+ const fileUpdateGroup = {
+ 'Series/Placeholder': ['cover.jpg']
+ }
+
+ const results = await LibraryScanner.scanFolderUpdates(library, folder, fileUpdateGroup)
+
+ expect(scanLibraryItemStub.called).to.be.false
+ expect(results['Series/Placeholder']).to.equal(ScanResult.NOTHING)
+ })
+
+ it('ignores missing checks for placeholders on file updates when path is gone', async () => {
+ const library = {
+ id: 'library-1',
+ name: 'Test Library',
+ mediaType: 'book',
+ settings: {}
+ }
+ const folder = {
+ id: 'folder-1',
+ path: '/library',
+ libraryId: library.id
+ }
+
+ const placeholderItem = {
+ id: 'item-1',
+ path: '/library/Series/Placeholder',
+ relPath: 'Series/Placeholder',
+ isPlaceholder: true,
+ isMissing: false,
+ media: { title: 'Placeholder Book' },
+ save: sinon.stub().resolves(),
+ changed: sinon.stub()
+ }
+
+ sinon.stub(libraryFilters, 'getFilterData').resolves()
+ sinon.stub(Database.libraryItemModel, 'findOneExpanded').resolves(placeholderItem)
+ sinon.stub(Database.libraryItemModel, 'findOne').resolves(null)
+ sinon.stub(fs, 'pathExists').resolves(false)
+ const scanLibraryItemStub = sinon.stub(LibraryItemScanner, 'scanLibraryItem').resolves(ScanResult.UPDATED)
+ sinon.stub(SocketAuthority, 'libraryItemEmitter')
+
+ const fileUpdateGroup = {
+ 'Series/Placeholder': ['track-01.mp3']
+ }
+
+ const results = await LibraryScanner.scanFolderUpdates(library, folder, fileUpdateGroup)
+
+ expect(scanLibraryItemStub.called).to.be.false
+ expect(results['Series/Placeholder']).to.equal(ScanResult.NOTHING)
+ })
+})
diff --git a/test/server/utils/queries/libraryFilters.placeholders.test.js b/test/server/utils/queries/libraryFilters.placeholders.test.js
new file mode 100644
index 000000000..2129a7927
--- /dev/null
+++ b/test/server/utils/queries/libraryFilters.placeholders.test.js
@@ -0,0 +1,89 @@
+const { expect } = require('chai')
+const { Sequelize } = require('sequelize')
+
+const Database = require('../../../../server/Database')
+const libraryFilters = require('../../../../server/utils/queries/libraryFilters')
+
+describe('libraryFilters placeholders', () => {
+ let library
+ let libraryFolder
+
+ 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 = { sortingPrefixes: [] }
+ await Database.buildModels()
+ Database.libraryFilterData = {}
+
+ library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
+ libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })
+ })
+
+ afterEach(async () => {
+ await Database.sequelize.sync({ force: true })
+ })
+
+ const createBookWithItem = async ({ title, tags, genres, narrators, publisher, publishedYear, language, isPlaceholder, isMissing }) => {
+ const book = await Database.bookModel.create({
+ title,
+ audioFiles: [],
+ tags,
+ narrators,
+ genres,
+ publisher,
+ publishedYear,
+ language,
+ chapters: []
+ })
+
+ await Database.libraryItemModel.create({
+ libraryFiles: [],
+ mediaId: book.id,
+ mediaType: 'book',
+ libraryId: library.id,
+ libraryFolderId: libraryFolder.id,
+ isPlaceholder,
+ isMissing
+ })
+ }
+
+ it('skips placeholder items when aggregating filter data', async () => {
+ await createBookWithItem({
+ title: 'Real Book',
+ tags: ['real-tag'],
+ genres: ['real-genre'],
+ narrators: ['real-narrator'],
+ publisher: 'Real Publisher',
+ publishedYear: 2001,
+ language: 'en',
+ isPlaceholder: false,
+ isMissing: false
+ })
+ await createBookWithItem({
+ title: 'Placeholder Book',
+ tags: ['placeholder-tag'],
+ genres: ['placeholder-genre'],
+ narrators: ['placeholder-narrator'],
+ publisher: 'Placeholder Publisher',
+ publishedYear: 1999,
+ language: 'fr',
+ isPlaceholder: true,
+ isMissing: true
+ })
+
+ const data = await libraryFilters.getFilterData('book', library.id)
+
+ expect(data.bookCount).to.equal(1)
+ expect(data.numIssues).to.equal(0)
+ expect(data.tags).to.include('real-tag')
+ expect(data.tags).to.not.include('placeholder-tag')
+ expect(data.genres).to.include('real-genre')
+ expect(data.genres).to.not.include('placeholder-genre')
+ expect(data.narrators).to.include('real-narrator')
+ expect(data.narrators).to.not.include('placeholder-narrator')
+ expect(data.publishers).to.include('Real Publisher')
+ expect(data.publishers).to.not.include('Placeholder Publisher')
+ expect(data.languages).to.include('en')
+ expect(data.languages).to.not.include('fr')
+ })
+})
diff --git a/test/server/utils/queries/libraryItemFilters.placeholders.test.js b/test/server/utils/queries/libraryItemFilters.placeholders.test.js
new file mode 100644
index 000000000..445cb184c
--- /dev/null
+++ b/test/server/utils/queries/libraryItemFilters.placeholders.test.js
@@ -0,0 +1,79 @@
+const { expect } = require('chai')
+const { Sequelize } = require('sequelize')
+
+const Database = require('../../../../server/Database')
+const libraryItemFilters = require('../../../../server/utils/queries/libraryItemFilters')
+
+describe('libraryItemFilters placeholders', () => {
+ let library
+ let libraryFolder
+
+ 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 = { sortingPrefixes: [] }
+ await Database.buildModels()
+
+ library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
+ libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })
+ })
+
+ afterEach(async () => {
+ await Database.sequelize.sync({ force: true })
+ })
+
+ const createBookWithItem = async ({ title, tags = [], genres = [], narrators = [], isPlaceholder = false }) => {
+ const book = await Database.bookModel.create({
+ title,
+ audioFiles: [],
+ tags,
+ narrators,
+ genres,
+ chapters: []
+ })
+
+ const libraryItem = await Database.libraryItemModel.create({
+ libraryFiles: [],
+ mediaId: book.id,
+ mediaType: 'book',
+ libraryId: library.id,
+ libraryFolderId: libraryFolder.id,
+ isPlaceholder
+ })
+
+ return { book, libraryItem }
+ }
+
+ it('excludes placeholders when filtering by tags', async () => {
+ const realItem = await createBookWithItem({ title: 'Real Book', tags: ['real-tag'], isPlaceholder: false })
+ await createBookWithItem({ title: 'Placeholder Book', tags: ['placeholder-tag'], isPlaceholder: true })
+
+ const items = await libraryItemFilters.getAllLibraryItemsWithTags(['real-tag', 'placeholder-tag'])
+
+ expect(items).to.have.length(1)
+ expect(items[0].id).to.equal(realItem.libraryItem.id)
+ expect(items[0].isPlaceholder).to.be.false
+ })
+
+ it('excludes placeholders when filtering by genres', async () => {
+ const realItem = await createBookWithItem({ title: 'Real Book', genres: ['real-genre'], isPlaceholder: false })
+ await createBookWithItem({ title: 'Placeholder Book', genres: ['placeholder-genre'], isPlaceholder: true })
+
+ const items = await libraryItemFilters.getAllLibraryItemsWithGenres(['real-genre', 'placeholder-genre'])
+
+ expect(items).to.have.length(1)
+ expect(items[0].id).to.equal(realItem.libraryItem.id)
+ expect(items[0].isPlaceholder).to.be.false
+ })
+
+ it('excludes placeholders when filtering by narrators', async () => {
+ const realItem = await createBookWithItem({ title: 'Real Book', narrators: ['real-narrator'], isPlaceholder: false })
+ await createBookWithItem({ title: 'Placeholder Book', narrators: ['placeholder-narrator'], isPlaceholder: true })
+
+ const items = await libraryItemFilters.getAllLibraryItemsWithNarrators(['real-narrator', 'placeholder-narrator'])
+
+ expect(items).to.have.length(1)
+ expect(items[0].id).to.equal(realItem.libraryItem.id)
+ expect(items[0].isPlaceholder).to.be.false
+ })
+})
diff --git a/test/server/utils/queries/libraryItemsBookFilters.placeholders.test.js b/test/server/utils/queries/libraryItemsBookFilters.placeholders.test.js
index 7d20f463f..74723c45a 100644
--- a/test/server/utils/queries/libraryItemsBookFilters.placeholders.test.js
+++ b/test/server/utils/queries/libraryItemsBookFilters.placeholders.test.js
@@ -3,6 +3,7 @@ const { Sequelize } = require('sequelize')
const Database = require('../../../../server/Database')
const libraryItemsBookFilters = require('../../../../server/utils/queries/libraryItemsBookFilters')
+const libraryFilters = require('../../../../server/utils/queries/libraryFilters')
const User = require('../../../../server/models/User')
describe('libraryItemsBookFilters placeholders', () => {
@@ -41,7 +42,7 @@ describe('libraryItemsBookFilters placeholders', () => {
chapters: []
})
- return Database.libraryItemModel.create({
+ const libraryItem = await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: book.id,
mediaType: 'book',
@@ -49,6 +50,35 @@ describe('libraryItemsBookFilters placeholders', () => {
libraryFolderId: libraryFolder.id,
isPlaceholder
})
+
+ return { book, libraryItem }
+ }
+
+ const createSeriesWithBook = async ({ seriesName, title, isPlaceholder }) => {
+ const series = await Database.seriesModel.create({
+ name: seriesName,
+ libraryId: library.id
+ })
+ const { book, libraryItem } = await createBookWithItem({ title, isPlaceholder })
+ await Database.bookSeriesModel.create({
+ seriesId: series.id,
+ bookId: book.id,
+ sequence: '1'
+ })
+ return { series, book, libraryItem }
+ }
+
+ const createAuthorWithBook = async ({ authorName, title, isPlaceholder }) => {
+ const author = await Database.authorModel.create({
+ name: authorName,
+ libraryId: library.id
+ })
+ const { book, libraryItem } = await createBookWithItem({ title, isPlaceholder })
+ await Database.bookAuthorModel.create({
+ authorId: author.id,
+ bookId: book.id
+ })
+ return { author, book, libraryItem }
}
it('excludes placeholders from general library list queries', async () => {
@@ -81,4 +111,28 @@ describe('libraryItemsBookFilters placeholders', () => {
expect(results.book).to.have.length(0)
})
+
+ 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 })
+
+ const items = await libraryItemsBookFilters.getLibraryItemsForSeries(real.series, user)
+
+ expect(items).to.have.length(2)
+ const titles = items.map((item) => item.media.title).sort()
+ expect(titles).to.deep.equal(['Placeholder Book', 'Real Book'])
+ })
+
+ 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 })
+
+ const { libraryItems, count } = await libraryFilters.getLibraryItemsForAuthor(real.author, user, 20, 0)
+
+ expect(count).to.equal(2)
+ const titles = libraryItems.map((item) => item.media.title).sort()
+ expect(titles).to.deep.equal(['Placeholder Book', 'Real Book'])
+ })
})