mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +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'
|
$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'
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue