diff --git a/docs/controllers/LibraryController.yaml b/docs/controllers/LibraryController.yaml index b985c05ec..d845bebd7 100644 --- a/docs/controllers/LibraryController.yaml +++ b/docs/controllers/LibraryController.yaml @@ -477,3 +477,51 @@ paths: $ref: '../objects/entities/Series.yaml#/components/schemas/seriesWithProgressAndRSS' '404': $ref: '#/components/responses/library404' + /api/libraries/{id}/series/{seriesId}/placeholders: + parameters: + - name: id + in: path + description: The ID of the library. + required: true + schema: + $ref: '../objects/Library.yaml#/components/schemas/libraryId' + - name: seriesId + in: path + description: The ID of the series. + required: true + schema: + $ref: '../objects/entities/Series.yaml#/components/schemas/seriesId' + post: + operationId: createSeriesPlaceholder + summary: Create series placeholder + description: Create a placeholder book item within a series for a library. If `folderId` is omitted, the server uses the library folder from an existing series item; if the series has no items, it falls back to the library's first folder. + tags: + - Libraries + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + title: + description: The placeholder title to use. + type: string + sequence: + $ref: '../objects/entities/Series.yaml#/components/schemas/sequence' + folderId: + description: Optional library folder to create the placeholder in. + $ref: '../objects/Folder.yaml#/components/schemas/folderId' + responses: + '200': + description: createSeriesPlaceholder OK + content: + application/json: + schema: + $ref: '../objects/LibraryItem.yaml#/components/schemas/libraryItemMinified' + '400': + description: Bad request + '403': + description: Forbidden + '404': + $ref: '#/components/responses/library404' diff --git a/docs/objects/LibraryItem.yaml b/docs/objects/LibraryItem.yaml index 107ba9f3f..cc2db9d5d 100644 --- a/docs/objects/LibraryItem.yaml +++ b/docs/objects/LibraryItem.yaml @@ -53,6 +53,9 @@ components: isInvalid: description: Whether the library item was scanned and no longer has media files. type: boolean + isPlaceholder: + description: Whether the library item is a placeholder without media files yet. + type: boolean mediaType: $ref: './mediaTypes/media.yaml#/components/schemas/mediaType' libraryItemMinified: diff --git a/docs/root.yaml b/docs/root.yaml index 4d6c055db..d624a9d9c 100644 --- a/docs/root.yaml +++ b/docs/root.yaml @@ -43,6 +43,8 @@ paths: $ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries~1{id}~1series' /api/libraries/{id}/series/{seriesId}: $ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries~1{id}~1series~1{seriesId}' + /api/libraries/{id}/series/{seriesId}/placeholders: + $ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries~1{id}~1series~1{seriesId}~1placeholders' /api/notifications: $ref: './controllers/NotificationController.yaml#/paths/~1api~1notifications' /api/notificationdata: diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 5247dbb06..354fba56e 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -9,7 +9,8 @@ const Database = require('../Database') const zipHelpers = require('../utils/zipHelpers') const { reqSupportsWebp } = require('../utils/index') const { ScanResult, AudioMimeType } = require('../utils/constants') -const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils') +const { getAudioMimeTypeFromExtname, encodeUriPath, sanitizeFilename, filePathToPOSIX } = require('../utils/fileUtils') +const { getTitleIgnorePrefix } = require('../utils') const LibraryItemScanner = require('../scanner/LibraryItemScanner') const AudioFileScanner = require('../scanner/AudioFileScanner') const Scanner = require('../scanner/Scanner') @@ -188,6 +189,9 @@ class LibraryItemController { */ async updateMedia(req, res) { const mediaPayload = req.body + const originalTitle = req.libraryItem.media?.title + const originalPath = req.libraryItem.path + const wasPlaceholder = !!req.libraryItem.isPlaceholder if (mediaPayload.url) { await LibraryItemController.prototype.uploadCover.bind(this)(req, res, false) @@ -242,6 +246,54 @@ class LibraryItemController { } } + if ( + wasPlaceholder && + typeof req.libraryItem.media?.title === 'string' && + req.libraryItem.media.title && + req.libraryItem.media.title !== originalTitle + ) { + 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)) + if (updatedPath !== originalPath) { + if (await fs.pathExists(updatedPath)) { + return res.status(400).send('Library item already exists at that path') + } + + const originalPathExists = await fs.pathExists(originalPath) + if (originalPathExists) { + try { + await fs.move(originalPath, updatedPath) + } catch (error) { + Logger.error(`[LibraryItemController] Failed to move placeholder path from "${originalPath}" to "${updatedPath}"`, error) + return res.status(500).send('Failed to move placeholder path') + } + } else if (global.ServerSettings.storeMetadataWithItem && !req.libraryItem.isFile) { + try { + await fs.ensureDir(updatedPath) + } catch (error) { + Logger.error(`[LibraryItemController] Failed to create placeholder path "${updatedPath}"`, error) + return res.status(500).send('Failed to create placeholder path') + } + } + + 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) + req.libraryItem.relPath = relPath + } + + req.libraryItem.path = updatedPath + req.libraryItem.title = req.libraryItem.media.title + req.libraryItem.titleIgnorePrefix = getTitleIgnorePrefix(req.libraryItem.media.title) + hasUpdates = true + } + } + if (hasUpdates) { req.libraryItem.changed('updatedAt', true) await req.libraryItem.save() diff --git a/server/controllers/SeriesController.js b/server/controllers/SeriesController.js index 21c93f332..93735fc19 100644 --- a/server/controllers/SeriesController.js +++ b/server/controllers/SeriesController.js @@ -1,7 +1,10 @@ +const Path = require('path') const { Request, Response, NextFunction } = require('express') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') +const { getTitleIgnorePrefix } = require('../utils') +const { sanitizeFilename, filePathToPOSIX } = require('../utils/fileUtils') const RssFeedManager = require('../managers/RssFeedManager') @@ -86,6 +89,197 @@ class SeriesController { res.json(req.series.toOldJSON()) } + /** + * POST: /api/libraries/:id/series/:seriesId/placeholders + * + * @param {SeriesControllerRequest} req + * @param {Response} res + */ + async createPlaceholder(req, res) { + if (!req.user.canUpdate) { + Logger.warn(`[SeriesController] 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 libraryId = req.params.id + const library = req.library || (await Database.libraryModel.findByIdWithFolders(libraryId)) + if (!library) { + return res.status(404).send('Library not found') + } + + if (!req.user.checkCanAccessLibrary(library.id)) { + Logger.warn(`[SeriesController] User "${req.user.username}" attempted to access library "${library.id}" without permission`) + return res.sendStatus(403) + } + + const series = await Database.seriesModel.findByPk(req.params.seriesId) + if (!series || series.libraryId !== library.id) { + return res.sendStatus(404) + } + + if (library.mediaType !== 'book') { + return res.status(400).send('Library media type does not support series placeholders') + } + + const requestedTitle = typeof req.body?.title === 'string' ? req.body.title.trim() : '' + const placeholderTitle = requestedTitle || 'Placeholder' + const requestedSequence = req.body?.sequence + const placeholderSequence = + typeof requestedSequence === 'string' || typeof requestedSequence === 'number' ? String(requestedSequence).trim() || null : null + + 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 seriesLibraryItem = await Database.libraryItemModel.findOne({ + where: { + libraryId: library.id, + mediaType: 'book' + }, + include: [ + { + model: Database.bookModel, + required: true, + include: [ + { + model: Database.seriesModel, + required: true, + where: { + id: series.id + }, + through: { + attributes: [] + } + } + ] + } + ], + order: [['createdAt', 'DESC']] + }) + + if (seriesLibraryItem?.libraryFolderId) { + libraryFolder = library.libraryFolders?.find((folder) => folder.id === seriesLibraryItem.libraryFolderId) || null + } + } + + if (!libraryFolder) { + libraryFolder = library.libraryFolders?.[0] || null + } + + if (!libraryFolder) { + Logger.error(`[SeriesController] Library "${library.id}" has no folders for placeholder creation`) + return res.status(400).send('Library has no folders') + } + + const outputDirectoryParts = [series.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') + } + + const libraryItemFolderStats = { + ino: null, + mtimeMs: 0, + ctimeMs: 0, + birthtimeMs: 0 + } + + 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.bookSeriesModel.create( + { + bookId: book.id, + seriesId: series.id, + sequence: placeholderSequence + }, + { transaction } + ) + + newLibraryItem = await Database.libraryItemModel.create( + { + ino: libraryItemFolderStats.ino, + path: outputDirectory, + relPath, + mediaId: book.id, + mediaType: 'book', + isFile: false, + isMissing: false, + isInvalid: false, + isPlaceholder: true, + mtime: libraryItemFolderStats.mtimeMs || 0, + ctime: libraryItemFolderStats.ctimeMs || 0, + birthtime: libraryItemFolderStats.birthtimeMs || 0, + size: 0, + libraryFiles: [], + extraData: {}, + libraryId: library.id, + libraryFolderId: libraryFolder.id, + title: placeholderTitle, + titleIgnorePrefix: getTitleIgnorePrefix(placeholderTitle), + authorNamesFirstLast: '', + authorNamesLastFirst: '' + }, + { transaction } + ) + + await transaction.commit() + } catch (error) { + Logger.error(`[SeriesController] Failed to create series 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()) + } + /** * * @param {RequestWithUser} req diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db04bf5ec..562d5f39c 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -77,6 +77,7 @@ class ApiRouter { this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this)) this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this)) this.router.get('/libraries/:id/series/:seriesId', LibraryController.middleware.bind(this), LibraryController.getSeriesForLibrary.bind(this)) + this.router.post('/libraries/:id/series/:seriesId/placeholders', LibraryController.middleware.bind(this), SeriesController.createPlaceholder.bind(this)) this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this)) this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this)) this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getUserPersonalizedShelves.bind(this)) diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index 5a0422392..bca6dd766 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -8,10 +8,14 @@ const LibraryItemController = require('../../../server/controllers/LibraryItemCo const ApiCacheManager = require('../../../server/managers/ApiCacheManager') const Auth = require('../../../server/Auth') const Logger = require('../../../server/Logger') +const fs = require('../../../server/libs/fsExtra') +const Path = require('path') +const { filePathToPOSIX, sanitizeFilename } = require('../../../server/utils/fileUtils') describe('LibraryItemController', () => { /** @type {ApiRouter} */ let apiRouter + let tempPaths = [] beforeEach(async () => { global.ServerSettings = {} @@ -32,6 +36,11 @@ describe('LibraryItemController', () => { // Clear all tables await Database.sequelize.sync({ force: true }) + + if (tempPaths.length) { + await Promise.all(tempPaths.map((tempPath) => fs.remove(tempPath).catch(() => null))) + tempPaths = [] + } }) describe('checkRemoveAuthorsAndSeries', () => { @@ -199,4 +208,212 @@ describe('LibraryItemController', () => { expect(series2Exists).to.be.true }) }) + + describe('updateMedia placeholder paths', () => { + it('renames placeholder folder when title 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 oldTitle = 'Old Title' + const newTitle = 'New Title' + const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series')) + const oldPath = filePathToPOSIX(Path.join(seriesDir, oldTitle)) + await fs.ensureDir(oldPath) + + const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const libraryItem = await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: book.id, + mediaType: 'book', + libraryId: library.id, + libraryFolderId: libraryFolder.id, + path: oldPath, + relPath: `Test Series/${oldTitle}`, + isFile: false, + isPlaceholder: true + }) + + const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id) + expandedItem.saveMetadataFile = sinon.stub().resolves() + + const fakeReq = { + query: {}, + body: { + metadata: { + title: newTitle + } + }, + 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(seriesDir, sanitizeFilename(newTitle))) + 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(`Test Series/${sanitizeFilename(newTitle)}`) + }) + + it('returns 400 when placeholder title sanitizes to empty', 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 oldTitle = 'Old Title' + const oldPath = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series', oldTitle)) + await fs.ensureDir(oldPath) + + const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const libraryItem = await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: book.id, + mediaType: 'book', + libraryId: library.id, + libraryFolderId: libraryFolder.id, + path: oldPath, + relPath: `Test Series/${oldTitle}`, + isFile: false, + isPlaceholder: true + }) + + const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id) + expandedItem.saveMetadataFile = sinon.stub().resolves() + + const fakeReq = { + query: {}, + body: { + metadata: { + title: '...' + } + }, + libraryItem: expandedItem + } + const fakeRes = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + expect(fakeRes.send.calledWith('Invalid placeholder title')).to.be.true + }) + + it('returns 400 when placeholder title update collides with existing path', 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 oldTitle = 'Old Title' + const newTitle = 'New Title' + const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series')) + const oldPath = filePathToPOSIX(Path.join(seriesDir, oldTitle)) + const newPath = filePathToPOSIX(Path.join(seriesDir, sanitizeFilename(newTitle))) + await fs.ensureDir(oldPath) + await fs.ensureDir(newPath) + + const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const libraryItem = await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: book.id, + mediaType: 'book', + libraryId: library.id, + libraryFolderId: libraryFolder.id, + path: oldPath, + relPath: `Test Series/${oldTitle}`, + isFile: false, + isPlaceholder: true + }) + + const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id) + expandedItem.saveMetadataFile = sinon.stub().resolves() + + const fakeReq = { + query: {}, + body: { + metadata: { + title: newTitle + } + }, + libraryItem: expandedItem + } + const fakeRes = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes) + + expect(fakeRes.status.calledWith(400)).to.be.true + expect(fakeRes.send.calledWith('Library item already exists at that path')).to.be.true + }) + + it('creates placeholder folder when metadata is stored with items and original path is missing', async () => { + global.ServerSettings = { storeMetadataWithItem: true } + 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 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: [] }) + const libraryItem = await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: book.id, + mediaType: 'book', + libraryId: library.id, + libraryFolderId: libraryFolder.id, + path: oldPath, + relPath: `Test Series/${oldTitle}`, + isFile: false, + isPlaceholder: true + }) + + const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id) + expandedItem.saveMetadataFile = sinon.stub().resolves() + + const fakeReq = { + query: {}, + body: { + metadata: { + title: newTitle + } + }, + libraryItem: expandedItem + } + const fakeRes = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + send: sinon.spy() + } + + await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes) + + const expectedPath = filePathToPOSIX(Path.join(seriesDir, sanitizeFilename(newTitle))) + const newPathExists = await fs.pathExists(expectedPath) + expect(newPathExists).to.be.true + }) + }) }) diff --git a/test/server/controllers/SeriesController.placeholders.test.js b/test/server/controllers/SeriesController.placeholders.test.js index 079e006fb..d0379c2f4 100644 --- a/test/server/controllers/SeriesController.placeholders.test.js +++ b/test/server/controllers/SeriesController.placeholders.test.js @@ -15,6 +15,7 @@ describe('SeriesController placeholders', () => { let apiRouter let library let libraryFolder + let secondaryLibraryFolder let series let user @@ -33,6 +34,7 @@ describe('SeriesController placeholders', () => { 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 }) series = await Database.seriesModel.create({ name: 'Test Series', libraryId: library.id }) user = await Database.userModel.create({ @@ -75,4 +77,143 @@ describe('SeriesController placeholders', () => { expect(payload).to.include({ mediaType: 'book', isPlaceholder: true }) expect(payload.media.metadata.series.some((entry) => entry.id === series.id)).to.be.true }) + + it('rejects cover url payloads for placeholder creation', async () => { + const fakeReq = { + params: { + id: library.id, + seriesId: series.id + }, + user, + body: { + url: 'http://example.com/cover.jpg' + } + } + + const fakeRes = { + status: sinon.stub().returnsThis(), + json: sinon.spy(), + send: sinon.spy(), + sendStatus: sinon.spy() + } + + await SeriesController.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('rejects cover file payloads for placeholder creation', async () => { + const fakeReq = { + params: { + id: library.id, + seriesId: series.id + }, + user, + body: {}, + files: { + cover: { + name: 'cover.jpg' + } + } + } + + const fakeRes = { + status: sinon.stub().returnsThis(), + json: sinon.spy(), + send: sinon.spy(), + sendStatus: sinon.spy() + } + + await SeriesController.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 series library folder when creating a placeholder', async () => { + const existingBook = await Database.bookModel.create({ + title: 'Existing Book', + audioFiles: [], + tags: [], + narrators: [], + genres: [], + chapters: [] + }) + await Database.bookSeriesModel.create({ bookId: existingBook.id, seriesId: series.id }) + await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: existingBook.id, + mediaType: 'book', + libraryId: library.id, + libraryFolderId: secondaryLibraryFolder.id + }) + + const fakeReq = { + params: { + id: library.id, + seriesId: series.id + }, + user, + body: {} + } + + const fakeRes = { + status: sinon.stub().returnsThis(), + json: sinon.spy(), + sendStatus: sinon.spy(), + send: sinon.spy() + } + + await SeriesController.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 + }) + + it('honors a requested folderId when creating a placeholder', async () => { + const existingBook = await Database.bookModel.create({ + title: 'Existing Book 2', + audioFiles: [], + tags: [], + narrators: [], + genres: [], + chapters: [] + }) + await Database.bookSeriesModel.create({ bookId: existingBook.id, seriesId: series.id }) + await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: existingBook.id, + mediaType: 'book', + libraryId: library.id, + libraryFolderId: secondaryLibraryFolder.id + }) + + const fakeReq = { + params: { + id: library.id, + seriesId: series.id + }, + user, + body: { + folderId: libraryFolder.id + } + } + + const fakeRes = { + status: sinon.stub().returnsThis(), + json: sinon.spy(), + sendStatus: sinon.spy(), + send: sinon.spy() + } + + await SeriesController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes) + + expect(fakeRes.json.calledOnce).to.be.true + const payload = fakeRes.json.firstCall.args[0] + expect(payload.folderId).to.equal(libraryFolder.id) + expect(payload.path.startsWith(libraryFolder.path)).to.be.true + }) }) diff --git a/test/server/models/LibraryItem.placeholder.test.js b/test/server/models/LibraryItem.placeholder.test.js index 3a459d8bd..655bd1437 100644 --- a/test/server/models/LibraryItem.placeholder.test.js +++ b/test/server/models/LibraryItem.placeholder.test.js @@ -15,7 +15,7 @@ describe('LibraryItem placeholder serialization', () => { await Database.sequelize.sync({ force: true }) }) - it('includes isPlaceholder in minified and expanded JSON', async () => { + it('includes isPlaceholder in default, minified, and expanded JSON', async () => { const library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) const libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id }) @@ -39,9 +39,11 @@ describe('LibraryItem placeholder serialization', () => { const expanded = await Database.libraryItemModel.getExpandedById(libraryItem.id) + const defaultJson = expanded.toOldJSON() const minifiedJson = expanded.toOldJSONMinified() const expandedJson = expanded.toOldJSONExpanded() + expect(defaultJson).to.have.property('isPlaceholder', true) expect(minifiedJson).to.have.property('isPlaceholder', true) expect(expandedJson).to.have.property('isPlaceholder', true) })