mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 18:01:42 +00:00
feat(series): add placeholder creation endpoint and reuse existing cover flow
This commit is contained in:
parent
dc52a6b2d3
commit
c0d9dc4e77
9 changed files with 662 additions and 2 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue