mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-08 02:11:35 +00:00
feat(server): promote placeholders on scan and enforce visibility rules across list/search/author-series contexts
This commit is contained in:
parent
c0d9dc4e77
commit
2a775d434a
16 changed files with 1190 additions and 19 deletions
170
test/server/controllers/AuthorController.placeholders.test.js
Normal file
170
test/server/controllers/AuthorController.placeholders.test.js
Normal 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
|
||||
})
|
||||
})
|
||||
|
|
@ -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}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
256
test/server/scanner/LibraryScanner.placeholders.test.js
Normal file
256
test/server/scanner/LibraryScanner.placeholders.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue