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

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