feat(server): promote placeholders on scan and enforce visibility rules across list/search/author-series contexts

This commit is contained in:
Jens Bickel 2026-02-09 17:19:20 +01:00
parent c0d9dc4e77
commit 2a775d434a
16 changed files with 1190 additions and 19 deletions

View file

@ -14,6 +14,8 @@
<button v-if="userCanUpdate" class="w-8 h-8 rounded-full flex items-center justify-center mx-4 cursor-pointer text-gray-300 hover:text-warning transform hover:scale-125 duration-100" @click="editAuthor">
<span class="material-symbols text-base">edit</span>
</button>
<ui-btn v-if="userCanUpdate" :loading="processingPlaceholder" color="bg-primary" small @click="createAuthorPlaceholder">{{ $strings.ButtonAddPlaceholder }}</ui-btn>
</div>
<p v-if="author.description" class="text-white/60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p>
@ -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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<boolean>}
*/
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()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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