feat(series): add placeholder creation endpoint and reuse existing cover flow

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

View file

@ -477,3 +477,51 @@ paths:
$ref: '../objects/entities/Series.yaml#/components/schemas/seriesWithProgressAndRSS' $ref: '../objects/entities/Series.yaml#/components/schemas/seriesWithProgressAndRSS'
'404': '404':
$ref: '#/components/responses/library404' $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'

View file

@ -53,6 +53,9 @@ components:
isInvalid: isInvalid:
description: Whether the library item was scanned and no longer has media files. description: Whether the library item was scanned and no longer has media files.
type: boolean type: boolean
isPlaceholder:
description: Whether the library item is a placeholder without media files yet.
type: boolean
mediaType: mediaType:
$ref: './mediaTypes/media.yaml#/components/schemas/mediaType' $ref: './mediaTypes/media.yaml#/components/schemas/mediaType'
libraryItemMinified: libraryItemMinified:

View file

@ -43,6 +43,8 @@ paths:
$ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries~1{id}~1series' $ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries~1{id}~1series'
/api/libraries/{id}/series/{seriesId}: /api/libraries/{id}/series/{seriesId}:
$ref: './controllers/LibraryController.yaml#/paths/~1api~1libraries~1{id}~1series~1{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: /api/notifications:
$ref: './controllers/NotificationController.yaml#/paths/~1api~1notifications' $ref: './controllers/NotificationController.yaml#/paths/~1api~1notifications'
/api/notificationdata: /api/notificationdata:

View file

@ -9,7 +9,8 @@ const Database = require('../Database')
const zipHelpers = require('../utils/zipHelpers') const zipHelpers = require('../utils/zipHelpers')
const { reqSupportsWebp } = require('../utils/index') const { reqSupportsWebp } = require('../utils/index')
const { ScanResult, AudioMimeType } = require('../utils/constants') 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 LibraryItemScanner = require('../scanner/LibraryItemScanner')
const AudioFileScanner = require('../scanner/AudioFileScanner') const AudioFileScanner = require('../scanner/AudioFileScanner')
const Scanner = require('../scanner/Scanner') const Scanner = require('../scanner/Scanner')
@ -188,6 +189,9 @@ class LibraryItemController {
*/ */
async updateMedia(req, res) { async updateMedia(req, res) {
const mediaPayload = req.body const mediaPayload = req.body
const originalTitle = req.libraryItem.media?.title
const originalPath = req.libraryItem.path
const wasPlaceholder = !!req.libraryItem.isPlaceholder
if (mediaPayload.url) { if (mediaPayload.url) {
await LibraryItemController.prototype.uploadCover.bind(this)(req, res, false) 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) { if (hasUpdates) {
req.libraryItem.changed('updatedAt', true) req.libraryItem.changed('updatedAt', true)
await req.libraryItem.save() await req.libraryItem.save()

View file

@ -1,7 +1,10 @@
const Path = require('path')
const { Request, Response, NextFunction } = require('express') const { Request, Response, NextFunction } = require('express')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const { getTitleIgnorePrefix } = require('../utils')
const { sanitizeFilename, filePathToPOSIX } = require('../utils/fileUtils')
const RssFeedManager = require('../managers/RssFeedManager') const RssFeedManager = require('../managers/RssFeedManager')
@ -86,6 +89,197 @@ class SeriesController {
res.json(req.series.toOldJSON()) 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 * @param {RequestWithUser} req

View file

@ -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/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', 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.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/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/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))
this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getUserPersonalizedShelves.bind(this)) this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getUserPersonalizedShelves.bind(this))

View file

@ -8,10 +8,14 @@ const LibraryItemController = require('../../../server/controllers/LibraryItemCo
const ApiCacheManager = require('../../../server/managers/ApiCacheManager') const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
const Auth = require('../../../server/Auth') const Auth = require('../../../server/Auth')
const Logger = require('../../../server/Logger') const Logger = require('../../../server/Logger')
const fs = require('../../../server/libs/fsExtra')
const Path = require('path')
const { filePathToPOSIX, sanitizeFilename } = require('../../../server/utils/fileUtils')
describe('LibraryItemController', () => { describe('LibraryItemController', () => {
/** @type {ApiRouter} */ /** @type {ApiRouter} */
let apiRouter let apiRouter
let tempPaths = []
beforeEach(async () => { beforeEach(async () => {
global.ServerSettings = {} global.ServerSettings = {}
@ -32,6 +36,11 @@ describe('LibraryItemController', () => {
// Clear all tables // Clear all tables
await Database.sequelize.sync({ force: true }) await Database.sequelize.sync({ force: true })
if (tempPaths.length) {
await Promise.all(tempPaths.map((tempPath) => fs.remove(tempPath).catch(() => null)))
tempPaths = []
}
}) })
describe('checkRemoveAuthorsAndSeries', () => { describe('checkRemoveAuthorsAndSeries', () => {
@ -199,4 +208,212 @@ describe('LibraryItemController', () => {
expect(series2Exists).to.be.true 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
})
})
}) })

View file

@ -15,6 +15,7 @@ describe('SeriesController placeholders', () => {
let apiRouter let apiRouter
let library let library
let libraryFolder let libraryFolder
let secondaryLibraryFolder
let series let series
let user let user
@ -33,6 +34,7 @@ describe('SeriesController placeholders', () => {
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' }) library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id }) 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 }) series = await Database.seriesModel.create({ name: 'Test Series', libraryId: library.id })
user = await Database.userModel.create({ user = await Database.userModel.create({
@ -75,4 +77,143 @@ describe('SeriesController placeholders', () => {
expect(payload).to.include({ mediaType: 'book', isPlaceholder: true }) expect(payload).to.include({ mediaType: 'book', isPlaceholder: true })
expect(payload.media.metadata.series.some((entry) => entry.id === series.id)).to.be.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
})
}) })

View file

@ -15,7 +15,7 @@ describe('LibraryItem placeholder serialization', () => {
await Database.sequelize.sync({ force: true }) 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 library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
const libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id }) 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 expanded = await Database.libraryItemModel.getExpandedById(libraryItem.id)
const defaultJson = expanded.toOldJSON()
const minifiedJson = expanded.toOldJSONMinified() const minifiedJson = expanded.toOldJSONMinified()
const expandedJson = expanded.toOldJSONExpanded() const expandedJson = expanded.toOldJSONExpanded()
expect(defaultJson).to.have.property('isPlaceholder', true)
expect(minifiedJson).to.have.property('isPlaceholder', true) expect(minifiedJson).to.have.property('isPlaceholder', true)
expect(expandedJson).to.have.property('isPlaceholder', true) expect(expandedJson).to.have.property('isPlaceholder', true)
}) })