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
|
|
@ -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