diff --git a/client/pages/author/_id.vue b/client/pages/author/_id.vue index 11b940acd..058933531 100644 --- a/client/pages/author/_id.vue +++ b/client/pages/author/_id.vue @@ -14,6 +14,8 @@ + + {{ $strings.ButtonAddPlaceholder }}

{{ $strings.LabelDescription }}

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