mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 18:01:42 +00:00
feat(server): promote placeholders on scan and enforce visibility rules across list/search/author-series contexts
This commit is contained in:
parent
c0d9dc4e77
commit
2a775d434a
16 changed files with 1190 additions and 19 deletions
|
|
@ -14,6 +14,8 @@
|
||||||
<button v-if="userCanUpdate" class="w-8 h-8 rounded-full flex items-center justify-center mx-4 cursor-pointer text-gray-300 hover:text-warning transform hover:scale-125 duration-100" @click="editAuthor">
|
<button v-if="userCanUpdate" class="w-8 h-8 rounded-full flex items-center justify-center mx-4 cursor-pointer text-gray-300 hover:text-warning transform hover:scale-125 duration-100" @click="editAuthor">
|
||||||
<span class="material-symbols text-base">edit</span>
|
<span class="material-symbols text-base">edit</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<ui-btn v-if="userCanUpdate" :loading="processingPlaceholder" color="bg-primary" small @click="createAuthorPlaceholder">{{ $strings.ButtonAddPlaceholder }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="author.description" class="text-white/60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p>
|
<p v-if="author.description" class="text-white/60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p>
|
||||||
|
|
@ -67,7 +69,8 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isDescriptionClamped: false,
|
isDescriptionClamped: false,
|
||||||
showFullDescription: false
|
showFullDescription: false,
|
||||||
|
processingPlaceholder: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -95,6 +98,25 @@ export default {
|
||||||
editAuthor() {
|
editAuthor() {
|
||||||
this.$store.commit('globals/showEditAuthorModal', this.author)
|
this.$store.commit('globals/showEditAuthorModal', this.author)
|
||||||
},
|
},
|
||||||
|
async createAuthorPlaceholder() {
|
||||||
|
if (!this.author?.id || this.processingPlaceholder) return
|
||||||
|
this.processingPlaceholder = true
|
||||||
|
const payload = {
|
||||||
|
title: this.$strings.LabelPlaceholderDefaultTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const libraryItem = await this.$axios.$post(`/api/authors/${this.author.id}/placeholders`, payload)
|
||||||
|
this.$toast.success(this.$strings.ToastPlaceholderCreated)
|
||||||
|
this.$store.commit('setBookshelfBookIds', [])
|
||||||
|
this.$store.commit('showEditModal', libraryItem)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create author placeholder', error)
|
||||||
|
this.$toast.error(this.$strings.ToastPlaceholderCreateFailed)
|
||||||
|
} finally {
|
||||||
|
this.processingPlaceholder = false
|
||||||
|
}
|
||||||
|
},
|
||||||
authorUpdated(author) {
|
authorUpdated(author) {
|
||||||
if (author.id === this.author.id) {
|
if (author.id === this.author.id) {
|
||||||
console.log('Author was updated', author)
|
console.log('Author was updated', author)
|
||||||
|
|
|
||||||
|
|
@ -300,3 +300,43 @@ paths:
|
||||||
- $ref: '#/components/schemas/authorUpdated'
|
- $ref: '#/components/schemas/authorUpdated'
|
||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/author404'
|
$ref: '#/components/responses/author404'
|
||||||
|
/api/authors/{id}/placeholders:
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
description: Author ID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '../objects/entities/Author.yaml#/components/schemas/authorId'
|
||||||
|
post:
|
||||||
|
operationId: createAuthorPlaceholder
|
||||||
|
summary: Create author placeholder
|
||||||
|
description: Create a placeholder book item for an author. If `folderId` is omitted, the server uses the library folder from an existing author item; if the author has no items, it falls back to the library's first folder.
|
||||||
|
tags:
|
||||||
|
- Authors
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
title:
|
||||||
|
description: The placeholder title to use.
|
||||||
|
type: string
|
||||||
|
folderId:
|
||||||
|
description: Optional library folder to create the placeholder in.
|
||||||
|
$ref: '../objects/Folder.yaml#/components/schemas/folderId'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: createAuthorPlaceholder OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '../objects/LibraryItem.yaml#/components/schemas/libraryItemMinified'
|
||||||
|
'400':
|
||||||
|
description: Bad request
|
||||||
|
'403':
|
||||||
|
description: Forbidden
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/author404'
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ paths:
|
||||||
$ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1image'
|
$ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1image'
|
||||||
/api/authors/{id}/match:
|
/api/authors/{id}/match:
|
||||||
$ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1match'
|
$ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1match'
|
||||||
|
/api/authors/{id}/placeholders:
|
||||||
|
$ref: './controllers/AuthorController.yaml#/paths/~1api~1authors~1{id}~1placeholders'
|
||||||
/api/emails/settings:
|
/api/emails/settings:
|
||||||
$ref: './controllers/EmailController.yaml#/paths/~1api~1emails~1settings'
|
$ref: './controllers/EmailController.yaml#/paths/~1api~1emails~1settings'
|
||||||
/api/emails/test:
|
/api/emails/test:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
const Path = require('path')
|
||||||
const { Request, Response, NextFunction } = require('express')
|
const { Request, Response, NextFunction } = require('express')
|
||||||
const sequelize = require('sequelize')
|
const sequelize = require('sequelize')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
|
|
@ -9,6 +10,8 @@ const Database = require('../Database')
|
||||||
const CacheManager = require('../managers/CacheManager')
|
const CacheManager = require('../managers/CacheManager')
|
||||||
const CoverManager = require('../managers/CoverManager')
|
const CoverManager = require('../managers/CoverManager')
|
||||||
const AuthorFinder = require('../finders/AuthorFinder')
|
const AuthorFinder = require('../finders/AuthorFinder')
|
||||||
|
const { getTitleIgnorePrefix } = require('../utils')
|
||||||
|
const { sanitizeFilename, filePathToPOSIX } = require('../utils/fileUtils')
|
||||||
|
|
||||||
const { reqSupportsWebp, isValidASIN } = require('../utils/index')
|
const { reqSupportsWebp, isValidASIN } = require('../utils/index')
|
||||||
|
|
||||||
|
|
@ -256,6 +259,186 @@ class AuthorController {
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: /api/authors/:id/placeholders
|
||||||
|
*
|
||||||
|
* @param {AuthorControllerRequest} req
|
||||||
|
* @param {Response} res
|
||||||
|
*/
|
||||||
|
async createPlaceholder(req, res) {
|
||||||
|
if (!req.user.canUpdate) {
|
||||||
|
Logger.warn(`[AuthorController] 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 library = await Database.libraryModel.findByIdWithFolders(req.author.libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return res.status(404).send('Library not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.user.checkCanAccessLibrary(library.id)) {
|
||||||
|
Logger.warn(`[AuthorController] User "${req.user.username}" attempted to access library "${library.id}" without permission`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (library.mediaType !== 'book') {
|
||||||
|
return res.status(400).send('Library media type does not support author placeholders')
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedTitle = typeof req.body?.title === 'string' ? req.body.title.trim() : ''
|
||||||
|
const placeholderTitle = requestedTitle || 'Placeholder'
|
||||||
|
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 authorLibraryItem = await Database.libraryItemModel.findOne({
|
||||||
|
where: {
|
||||||
|
libraryId: library.id,
|
||||||
|
mediaType: 'book'
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.bookModel,
|
||||||
|
required: true,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.authorModel,
|
||||||
|
required: true,
|
||||||
|
where: {
|
||||||
|
id: req.author.id
|
||||||
|
},
|
||||||
|
through: {
|
||||||
|
attributes: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']]
|
||||||
|
})
|
||||||
|
|
||||||
|
if (authorLibraryItem?.libraryFolderId) {
|
||||||
|
libraryFolder = library.libraryFolders?.find((folder) => folder.id === authorLibraryItem.libraryFolderId) || null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!libraryFolder) {
|
||||||
|
libraryFolder = library.libraryFolders?.[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!libraryFolder) {
|
||||||
|
Logger.error(`[AuthorController] Library "${library.id}" has no folders for placeholder creation`)
|
||||||
|
return res.status(400).send('Library has no folders')
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputDirectoryParts = [req.author.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')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.ensureDir(outputDirectory)
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[AuthorController] Failed to create placeholder directory "${outputDirectory}"`, error)
|
||||||
|
return res.status(500).send('Failed to create placeholder directory')
|
||||||
|
}
|
||||||
|
|
||||||
|
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.bookAuthorModel.create(
|
||||||
|
{
|
||||||
|
bookId: book.id,
|
||||||
|
authorId: req.author.id
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
)
|
||||||
|
|
||||||
|
newLibraryItem = await Database.libraryItemModel.create(
|
||||||
|
{
|
||||||
|
ino: null,
|
||||||
|
path: outputDirectory,
|
||||||
|
relPath,
|
||||||
|
mediaId: book.id,
|
||||||
|
mediaType: 'book',
|
||||||
|
isFile: false,
|
||||||
|
isMissing: false,
|
||||||
|
isInvalid: false,
|
||||||
|
isPlaceholder: true,
|
||||||
|
mtime: 0,
|
||||||
|
ctime: 0,
|
||||||
|
birthtime: 0,
|
||||||
|
size: 0,
|
||||||
|
libraryFiles: [],
|
||||||
|
extraData: {},
|
||||||
|
libraryId: library.id,
|
||||||
|
libraryFolderId: libraryFolder.id,
|
||||||
|
title: placeholderTitle,
|
||||||
|
titleIgnorePrefix: getTitleIgnorePrefix(placeholderTitle),
|
||||||
|
authorNamesFirstLast: req.author.name,
|
||||||
|
authorNamesLastFirst: req.author.lastFirst || ''
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
)
|
||||||
|
|
||||||
|
await transaction.commit()
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error('[AuthorController] Failed to create author 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())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST: /api/authors/:id/image
|
* POST: /api/authors/:id/image
|
||||||
* Upload author image from web URL
|
* Upload author image from web URL
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,43 @@ class LibraryItemController {
|
||||||
const originalPath = req.libraryItem.path
|
const originalPath = req.libraryItem.path
|
||||||
const wasPlaceholder = !!req.libraryItem.isPlaceholder
|
const wasPlaceholder = !!req.libraryItem.isPlaceholder
|
||||||
|
|
||||||
|
const placeholderAuthorDirectory = (() => {
|
||||||
|
const authorNamesFirstLast = typeof req.libraryItem.authorNamesFirstLast === 'string' ? req.libraryItem.authorNamesFirstLast.trim() : ''
|
||||||
|
if (!authorNamesFirstLast) return ''
|
||||||
|
return sanitizeFilename(authorNamesFirstLast)
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (wasPlaceholder && !req.libraryItem.isFile) {
|
||||||
|
const nextTitleRaw = typeof mediaPayload?.metadata?.title === 'string' ? mediaPayload.metadata.title : req.libraryItem.media?.title || ''
|
||||||
|
const nextTitle = sanitizeFilename(nextTitleRaw)
|
||||||
|
if (!nextTitle) {
|
||||||
|
return res.status(400).send('Invalid placeholder title')
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextSeriesName = null
|
||||||
|
if (Array.isArray(mediaPayload?.metadata?.series) && mediaPayload.metadata.series.length) {
|
||||||
|
nextSeriesName = mediaPayload.metadata.series[0]?.name || null
|
||||||
|
} else if (Array.isArray(req.libraryItem.media?.series) && req.libraryItem.media.series.length) {
|
||||||
|
nextSeriesName = req.libraryItem.media.series[0]?.name || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryFolder = await Database.libraryFolderModel.findByPk(req.libraryItem.libraryFolderId)
|
||||||
|
if (libraryFolder?.path) {
|
||||||
|
const nextRelPathParts = []
|
||||||
|
if (placeholderAuthorDirectory) nextRelPathParts.push(placeholderAuthorDirectory)
|
||||||
|
if (nextSeriesName) {
|
||||||
|
const sanitizedNextSeriesName = sanitizeFilename(nextSeriesName)
|
||||||
|
if (sanitizedNextSeriesName) nextRelPathParts.push(sanitizedNextSeriesName)
|
||||||
|
}
|
||||||
|
nextRelPathParts.push(nextTitle)
|
||||||
|
|
||||||
|
const nextPath = filePathToPOSIX(Path.join(libraryFolder.path, ...nextRelPathParts))
|
||||||
|
if (nextPath !== originalPath && (await fs.pathExists(nextPath))) {
|
||||||
|
return res.status(400).send('Library item already exists at that path')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (mediaPayload.url) {
|
if (mediaPayload.url) {
|
||||||
await LibraryItemController.prototype.uploadCover.bind(this)(req, res, false)
|
await LibraryItemController.prototype.uploadCover.bind(this)(req, res, false)
|
||||||
if (res.writableEnded || res.headersSent) return
|
if (res.writableEnded || res.headersSent) return
|
||||||
|
|
@ -246,18 +283,33 @@ class LibraryItemController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const placeholderTitleUpdated = wasPlaceholder && typeof req.libraryItem.media?.title === 'string' && req.libraryItem.media.title && req.libraryItem.media.title !== originalTitle
|
||||||
wasPlaceholder &&
|
const placeholderSeriesUpdated = wasPlaceholder && req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)
|
||||||
typeof req.libraryItem.media?.title === 'string' &&
|
|
||||||
req.libraryItem.media.title &&
|
if ((placeholderTitleUpdated || placeholderSeriesUpdated) && !req.libraryItem.isFile) {
|
||||||
req.libraryItem.media.title !== originalTitle
|
|
||||||
) {
|
|
||||||
const sanitizedTitle = sanitizeFilename(req.libraryItem.media.title)
|
const sanitizedTitle = sanitizeFilename(req.libraryItem.media.title)
|
||||||
if (!sanitizedTitle) {
|
if (!sanitizedTitle) {
|
||||||
return res.status(400).send('Invalid placeholder title')
|
return res.status(400).send('Invalid placeholder title')
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedPath = filePathToPOSIX(Path.join(Path.dirname(originalPath), sanitizedTitle))
|
const libraryFolder = await Database.libraryFolderModel.findByPk(req.libraryItem.libraryFolderId)
|
||||||
|
|
||||||
|
let updatedSeriesName = null
|
||||||
|
if (Array.isArray(req.libraryItem.media?.series) && req.libraryItem.media.series.length) {
|
||||||
|
updatedSeriesName = req.libraryItem.media.series[0]?.name || null
|
||||||
|
} else if (Array.isArray(mediaPayload.metadata?.series) && mediaPayload.metadata.series.length) {
|
||||||
|
updatedSeriesName = mediaPayload.metadata.series[0]?.name || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRelPathParts = []
|
||||||
|
if (placeholderAuthorDirectory) nextRelPathParts.push(placeholderAuthorDirectory)
|
||||||
|
if (updatedSeriesName) {
|
||||||
|
const sanitizedUpdatedSeriesName = sanitizeFilename(updatedSeriesName)
|
||||||
|
if (sanitizedUpdatedSeriesName) nextRelPathParts.push(sanitizedUpdatedSeriesName)
|
||||||
|
}
|
||||||
|
nextRelPathParts.push(sanitizedTitle)
|
||||||
|
|
||||||
|
const updatedPath = libraryFolder?.path ? filePathToPOSIX(Path.join(libraryFolder.path, ...nextRelPathParts)) : filePathToPOSIX(Path.join(Path.dirname(originalPath), sanitizedTitle))
|
||||||
if (updatedPath !== originalPath) {
|
if (updatedPath !== originalPath) {
|
||||||
if (await fs.pathExists(updatedPath)) {
|
if (await fs.pathExists(updatedPath)) {
|
||||||
return res.status(400).send('Library item already exists at that path')
|
return res.status(400).send('Library item already exists at that path')
|
||||||
|
|
@ -280,7 +332,6 @@ class LibraryItemController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryFolder = await Database.libraryFolderModel.findByPk(req.libraryItem.libraryFolderId)
|
|
||||||
if (libraryFolder?.path) {
|
if (libraryFolder?.path) {
|
||||||
let relPath = updatedPath.replace(filePathToPOSIX(libraryFolder.path), '')
|
let relPath = updatedPath.replace(filePathToPOSIX(libraryFolder.path), '')
|
||||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,7 @@ class ApiRouter {
|
||||||
// Author Routes
|
// Author Routes
|
||||||
//
|
//
|
||||||
this.router.get('/authors/:id', AuthorController.middleware.bind(this), AuthorController.findOne.bind(this))
|
this.router.get('/authors/:id', AuthorController.middleware.bind(this), AuthorController.findOne.bind(this))
|
||||||
|
this.router.post('/authors/:id/placeholders', AuthorController.middleware.bind(this), AuthorController.createPlaceholder.bind(this))
|
||||||
this.router.patch('/authors/:id', AuthorController.middleware.bind(this), AuthorController.update.bind(this))
|
this.router.patch('/authors/:id', AuthorController.middleware.bind(this), AuthorController.update.bind(this))
|
||||||
this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this))
|
this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this))
|
||||||
this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this))
|
this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this))
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,8 @@ class LibraryScanner {
|
||||||
// Podcast folder can have no episodes and still be valid
|
// Podcast folder can have no episodes and still be valid
|
||||||
if (libraryScan.libraryMediaType === 'podcast' && (await fs.pathExists(existingLibraryItem.path))) {
|
if (libraryScan.libraryMediaType === 'podcast' && (await fs.pathExists(existingLibraryItem.path))) {
|
||||||
libraryScan.addLog(LogLevel.INFO, `Library item "${existingLibraryItem.relPath}" folder exists but has no episodes`)
|
libraryScan.addLog(LogLevel.INFO, `Library item "${existingLibraryItem.relPath}" folder exists but has no episodes`)
|
||||||
|
} else if (existingLibraryItem.isPlaceholder) {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Placeholder library item "${existingLibraryItem.relPath}" was not found during scan - skipping missing check`)
|
||||||
} else {
|
} else {
|
||||||
libraryScan.addLog(LogLevel.WARN, `Library Item "${existingLibraryItem.path}" (inode: ${existingLibraryItem.ino}) is missing`)
|
libraryScan.addLog(LogLevel.WARN, `Library Item "${existingLibraryItem.path}" (inode: ${existingLibraryItem.ino}) is missing`)
|
||||||
libraryScan.resultsMissing++
|
libraryScan.resultsMissing++
|
||||||
|
|
@ -201,6 +203,21 @@ class LibraryScanner {
|
||||||
} else {
|
} else {
|
||||||
libraryItemDataFound = libraryItemDataFound.filter((lidf) => lidf !== libraryItemData)
|
libraryItemDataFound = libraryItemDataFound.filter((lidf) => lidf !== libraryItemData)
|
||||||
let libraryItemDataUpdated = await libraryItemData.checkLibraryItemData(existingLibraryItem, libraryScan)
|
let libraryItemDataUpdated = await libraryItemData.checkLibraryItemData(existingLibraryItem, libraryScan)
|
||||||
|
const placeholderHasMedia = existingLibraryItem.isPlaceholder && (libraryItemData.audioLibraryFiles.length || libraryItemData.ebookLibraryFiles.length)
|
||||||
|
if (existingLibraryItem.isPlaceholder && !placeholderHasMedia) {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Placeholder library item "${existingLibraryItem.relPath}" has no media files - skipping rescan`)
|
||||||
|
if (libraryItemDataUpdated) {
|
||||||
|
libraryScan.resultsUpdated++
|
||||||
|
const libraryItem = await Database.libraryItemModel.getExpandedById(existingLibraryItem.id)
|
||||||
|
if (libraryItem) {
|
||||||
|
libraryItemsUpdated.push(libraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (placeholderHasMedia) {
|
||||||
|
await this.promotePlaceholder(existingLibraryItem, libraryScan)
|
||||||
|
}
|
||||||
if (libraryItemDataUpdated || forceRescan) {
|
if (libraryItemDataUpdated || forceRescan) {
|
||||||
if (forceRescan || libraryItemData.hasLibraryFileChanges || libraryItemData.hasPathChange) {
|
if (forceRescan || libraryItemData.hasLibraryFileChanges || libraryItemData.hasPathChange) {
|
||||||
const { libraryItem, wasUpdated } = await LibraryItemScanner.rescanLibraryItemMedia(existingLibraryItem, libraryItemData, libraryScan.library.settings, libraryScan)
|
const { libraryItem, wasUpdated } = await LibraryItemScanner.rescanLibraryItemMedia(existingLibraryItem, libraryItemData, libraryScan.library.settings, libraryScan)
|
||||||
|
|
@ -590,6 +607,11 @@ class LibraryScanner {
|
||||||
if (existingLibraryItem.path === fullPath) {
|
if (existingLibraryItem.path === fullPath) {
|
||||||
const exists = await fs.pathExists(fullPath)
|
const exists = await fs.pathExists(fullPath)
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
|
if (existingLibraryItem.isPlaceholder) {
|
||||||
|
Logger.info(`[LibraryScanner] Scanning file update group and placeholder library item was deleted "${existingLibraryItem.media.title}" - ignoring missing check`)
|
||||||
|
itemGroupingResults[itemDir] = ScanResult.NOTHING
|
||||||
|
continue
|
||||||
|
}
|
||||||
Logger.info(`[LibraryScanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.title}" - marking as missing`)
|
Logger.info(`[LibraryScanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.title}" - marking as missing`)
|
||||||
existingLibraryItem.isMissing = true
|
existingLibraryItem.isMissing = true
|
||||||
await existingLibraryItem.save()
|
await existingLibraryItem.save()
|
||||||
|
|
@ -599,6 +621,15 @@ class LibraryScanner {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (existingLibraryItem.isPlaceholder) {
|
||||||
|
const placeholderHasAudio = hasAudioFiles(fileUpdateGroup, itemDir)
|
||||||
|
if (!placeholderHasAudio) {
|
||||||
|
Logger.debug(`[LibraryScanner] Placeholder library item "${existingLibraryItem.relPath}" has no audio file updates - skipping scan`)
|
||||||
|
itemGroupingResults[itemDir] = ScanResult.NOTHING
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await this.promotePlaceholder(existingLibraryItem)
|
||||||
|
}
|
||||||
// Scan library item for updates
|
// Scan library item for updates
|
||||||
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.title}" with id "${existingLibraryItem.id}" - scan for updates`)
|
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.title}" with id "${existingLibraryItem.id}" - scan for updates`)
|
||||||
itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, updatedLibraryItemDetails)
|
itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, updatedLibraryItemDetails)
|
||||||
|
|
@ -639,6 +670,36 @@ class LibraryScanner {
|
||||||
|
|
||||||
return itemGroupingResults
|
return itemGroupingResults
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promote a placeholder item when media files are detected.
|
||||||
|
* @param {import('../models/LibraryItem')} existingLibraryItem
|
||||||
|
* @param {import('./LibraryScan')} [libraryScan]
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async promotePlaceholder(existingLibraryItem, libraryScan = null) {
|
||||||
|
if (!existingLibraryItem?.isPlaceholder) return false
|
||||||
|
|
||||||
|
existingLibraryItem.isPlaceholder = false
|
||||||
|
if (existingLibraryItem.isMissing) {
|
||||||
|
existingLibraryItem.isMissing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingLibraryItem.changed) {
|
||||||
|
existingLibraryItem.changed('isPlaceholder', true)
|
||||||
|
if (existingLibraryItem.isMissing === false) {
|
||||||
|
existingLibraryItem.changed('isMissing', true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await existingLibraryItem.save()
|
||||||
|
|
||||||
|
if (libraryScan) {
|
||||||
|
libraryScan.addLog(LogLevel.INFO, `Promoted placeholder library item "${existingLibraryItem.relPath}" to a real item`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = new LibraryScanner()
|
module.exports = new LibraryScanner()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -553,7 +553,8 @@ module.exports = {
|
||||||
model: Database.libraryItemModel,
|
model: Database.libraryItemModel,
|
||||||
attributes: [],
|
attributes: [],
|
||||||
where: {
|
where: {
|
||||||
libraryId: libraryId
|
libraryId: libraryId,
|
||||||
|
isPlaceholder: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -582,6 +583,7 @@ module.exports = {
|
||||||
attributes: [],
|
attributes: [],
|
||||||
where: {
|
where: {
|
||||||
libraryId: libraryId,
|
libraryId: libraryId,
|
||||||
|
isPlaceholder: false,
|
||||||
updatedAt: {
|
updatedAt: {
|
||||||
[Sequelize.Op.gt]: new Date(lastLoadedAt)
|
[Sequelize.Op.gt]: new Date(lastLoadedAt)
|
||||||
}
|
}
|
||||||
|
|
@ -636,7 +638,8 @@ module.exports = {
|
||||||
model: Database.libraryItemModel,
|
model: Database.libraryItemModel,
|
||||||
attributes: ['isMissing', 'isInvalid'],
|
attributes: ['isMissing', 'isInvalid'],
|
||||||
where: {
|
where: {
|
||||||
libraryId: libraryId
|
libraryId: libraryId,
|
||||||
|
isPlaceholder: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
attributes: ['tags', 'genres', 'publisher', 'publishedYear', 'narrators', 'language']
|
attributes: ['tags', 'genres', 'publisher', 'publishedYear', 'narrators', 'language']
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@ module.exports = {
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Database.libraryItemModel
|
model: Database.libraryItemModel,
|
||||||
|
where: {
|
||||||
|
isPlaceholder: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: Database.authorModel,
|
model: Database.authorModel,
|
||||||
|
|
@ -54,7 +57,10 @@ module.exports = {
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Database.libraryItemModel
|
model: Database.libraryItemModel,
|
||||||
|
where: {
|
||||||
|
isPlaceholder: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: Database.podcastEpisodeModel
|
model: Database.podcastEpisodeModel
|
||||||
|
|
@ -85,7 +91,10 @@ module.exports = {
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Database.libraryItemModel
|
model: Database.libraryItemModel,
|
||||||
|
where: {
|
||||||
|
isPlaceholder: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: Database.authorModel,
|
model: Database.authorModel,
|
||||||
|
|
@ -115,7 +124,10 @@ module.exports = {
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Database.libraryItemModel
|
model: Database.libraryItemModel,
|
||||||
|
where: {
|
||||||
|
isPlaceholder: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: Database.podcastEpisodeModel
|
model: Database.podcastEpisodeModel
|
||||||
|
|
@ -146,7 +158,10 @@ module.exports = {
|
||||||
},
|
},
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: Database.libraryItemModel
|
model: Database.libraryItemModel,
|
||||||
|
where: {
|
||||||
|
isPlaceholder: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: Database.authorModel,
|
model: Database.authorModel,
|
||||||
|
|
|
||||||
|
|
@ -408,8 +408,10 @@ module.exports = {
|
||||||
|
|
||||||
let bookAttributes = null
|
let bookAttributes = null
|
||||||
|
|
||||||
|
const allowPlaceholderItems = filterGroup === 'series' || filterGroup === 'authors'
|
||||||
const libraryItemWhere = {
|
const libraryItemWhere = {
|
||||||
libraryId
|
libraryId,
|
||||||
|
...(allowPlaceholderItems ? {} : { isPlaceholder: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
let seriesInclude = {
|
let seriesInclude = {
|
||||||
|
|
@ -1079,7 +1081,8 @@ module.exports = {
|
||||||
{
|
{
|
||||||
model: Database.libraryItemModel,
|
model: Database.libraryItemModel,
|
||||||
where: {
|
where: {
|
||||||
libraryId: library.id
|
libraryId: library.id,
|
||||||
|
isPlaceholder: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
170
test/server/controllers/AuthorController.placeholders.test.js
Normal file
170
test/server/controllers/AuthorController.placeholders.test.js
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
const ApiRouter = require('../../../server/routers/ApiRouter')
|
||||||
|
const AuthorController = require('../../../server/controllers/AuthorController')
|
||||||
|
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
||||||
|
const Auth = require('../../../server/Auth')
|
||||||
|
const Logger = require('../../../server/Logger')
|
||||||
|
const User = require('../../../server/models/User')
|
||||||
|
const fs = require('../../../server/libs/fsExtra')
|
||||||
|
|
||||||
|
describe('AuthorController placeholders', () => {
|
||||||
|
/** @type {ApiRouter} */
|
||||||
|
let apiRouter
|
||||||
|
let library
|
||||||
|
let libraryFolder
|
||||||
|
let secondaryLibraryFolder
|
||||||
|
let author
|
||||||
|
let user
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
global.ServerSettings = {}
|
||||||
|
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
|
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||||
|
await Database.buildModels()
|
||||||
|
|
||||||
|
apiRouter = new ApiRouter({
|
||||||
|
auth: new Auth(),
|
||||||
|
apiCacheManager: new ApiCacheManager()
|
||||||
|
})
|
||||||
|
|
||||||
|
sinon.stub(Logger, 'warn')
|
||||||
|
sinon.stub(fs, 'ensureDir').resolves()
|
||||||
|
|
||||||
|
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 })
|
||||||
|
author = await Database.authorModel.create({ name: 'Jane Doe', lastFirst: 'Doe, Jane', libraryId: library.id })
|
||||||
|
|
||||||
|
user = await Database.userModel.create({
|
||||||
|
username: 'Admin',
|
||||||
|
type: 'admin',
|
||||||
|
isActive: true,
|
||||||
|
permissions: User.getDefaultPermissionsForUserType('admin'),
|
||||||
|
bookmarks: [],
|
||||||
|
extraData: { seriesHideFromContinueListening: [] }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
sinon.restore()
|
||||||
|
await Database.sequelize.sync({ force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates placeholder items for an author', async () => {
|
||||||
|
const fakeReq = {
|
||||||
|
params: {
|
||||||
|
id: author.id
|
||||||
|
},
|
||||||
|
author,
|
||||||
|
user,
|
||||||
|
body: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeRes = {
|
||||||
|
status: sinon.stub().returnsThis(),
|
||||||
|
json: sinon.spy(),
|
||||||
|
sendStatus: sinon.spy()
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuthorController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fakeRes.json.calledOnce).to.be.true
|
||||||
|
const payload = fakeRes.json.firstCall.args[0]
|
||||||
|
expect(payload).to.include({ mediaType: 'book', isPlaceholder: true })
|
||||||
|
expect(payload.media.metadata.authors.some((entry) => entry.id === author.id)).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates author placeholder directories on disk', async () => {
|
||||||
|
const fakeReq = {
|
||||||
|
params: {
|
||||||
|
id: author.id
|
||||||
|
},
|
||||||
|
author,
|
||||||
|
user,
|
||||||
|
body: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeRes = {
|
||||||
|
status: sinon.stub().returnsThis(),
|
||||||
|
json: sinon.spy(),
|
||||||
|
sendStatus: sinon.spy(),
|
||||||
|
send: sinon.spy()
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuthorController.createPlaceholder.bind(apiRouter)(fakeReq, fakeRes)
|
||||||
|
|
||||||
|
expect(fs.ensureDir.calledOnce).to.be.true
|
||||||
|
expect(fs.ensureDir.firstCall.args[0]).to.equal('/test/Jane Doe/Placeholder')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects cover url payloads for author placeholder creation', async () => {
|
||||||
|
const fakeReq = {
|
||||||
|
params: {
|
||||||
|
id: author.id
|
||||||
|
},
|
||||||
|
author,
|
||||||
|
user,
|
||||||
|
body: {
|
||||||
|
url: 'http://example.com/cover.jpg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeRes = {
|
||||||
|
status: sinon.stub().returnsThis(),
|
||||||
|
json: sinon.spy(),
|
||||||
|
send: sinon.spy(),
|
||||||
|
sendStatus: sinon.spy()
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuthorController.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 authors library folder when creating a placeholder', async () => {
|
||||||
|
const existingBook = await Database.bookModel.create({
|
||||||
|
title: 'Existing Book',
|
||||||
|
audioFiles: [],
|
||||||
|
tags: [],
|
||||||
|
narrators: [],
|
||||||
|
genres: [],
|
||||||
|
chapters: []
|
||||||
|
})
|
||||||
|
await Database.bookAuthorModel.create({ bookId: existingBook.id, authorId: author.id })
|
||||||
|
await Database.libraryItemModel.create({
|
||||||
|
libraryFiles: [],
|
||||||
|
mediaId: existingBook.id,
|
||||||
|
mediaType: 'book',
|
||||||
|
libraryId: library.id,
|
||||||
|
libraryFolderId: secondaryLibraryFolder.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const fakeReq = {
|
||||||
|
params: {
|
||||||
|
id: author.id
|
||||||
|
},
|
||||||
|
author,
|
||||||
|
user,
|
||||||
|
body: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeRes = {
|
||||||
|
status: sinon.stub().returnsThis(),
|
||||||
|
json: sinon.spy(),
|
||||||
|
sendStatus: sinon.spy(),
|
||||||
|
send: sinon.spy()
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuthorController.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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -216,6 +216,7 @@ describe('LibraryItemController', () => {
|
||||||
tempPaths.push(tempRoot)
|
tempPaths.push(tempRoot)
|
||||||
|
|
||||||
const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id })
|
const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id })
|
||||||
|
const series = await Database.seriesModel.create({ name: 'Test Series', libraryId: library.id })
|
||||||
const oldTitle = 'Old Title'
|
const oldTitle = 'Old Title'
|
||||||
const newTitle = 'New Title'
|
const newTitle = 'New Title'
|
||||||
const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series'))
|
const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series'))
|
||||||
|
|
@ -223,6 +224,7 @@ describe('LibraryItemController', () => {
|
||||||
await fs.ensureDir(oldPath)
|
await fs.ensureDir(oldPath)
|
||||||
|
|
||||||
const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id })
|
||||||
const libraryItem = await Database.libraryItemModel.create({
|
const libraryItem = await Database.libraryItemModel.create({
|
||||||
libraryFiles: [],
|
libraryFiles: [],
|
||||||
mediaId: book.id,
|
mediaId: book.id,
|
||||||
|
|
@ -321,6 +323,7 @@ describe('LibraryItemController', () => {
|
||||||
tempPaths.push(tempRoot)
|
tempPaths.push(tempRoot)
|
||||||
|
|
||||||
const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id })
|
const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id })
|
||||||
|
const series = await Database.seriesModel.create({ name: 'Test Series', libraryId: library.id })
|
||||||
const oldTitle = 'Old Title'
|
const oldTitle = 'Old Title'
|
||||||
const newTitle = 'New Title'
|
const newTitle = 'New Title'
|
||||||
const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series'))
|
const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series'))
|
||||||
|
|
@ -330,6 +333,7 @@ describe('LibraryItemController', () => {
|
||||||
await fs.ensureDir(newPath)
|
await fs.ensureDir(newPath)
|
||||||
|
|
||||||
const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id })
|
||||||
const libraryItem = await Database.libraryItemModel.create({
|
const libraryItem = await Database.libraryItemModel.create({
|
||||||
libraryFiles: [],
|
libraryFiles: [],
|
||||||
mediaId: book.id,
|
mediaId: book.id,
|
||||||
|
|
@ -364,6 +368,13 @@ describe('LibraryItemController', () => {
|
||||||
|
|
||||||
expect(fakeRes.status.calledWith(400)).to.be.true
|
expect(fakeRes.status.calledWith(400)).to.be.true
|
||||||
expect(fakeRes.send.calledWith('Library item already exists at that path')).to.be.true
|
expect(fakeRes.send.calledWith('Library item already exists at that path')).to.be.true
|
||||||
|
|
||||||
|
const updatedItem = await Database.libraryItemModel.findByPk(libraryItem.id)
|
||||||
|
expect(updatedItem.path).to.equal(oldPath)
|
||||||
|
expect(updatedItem.relPath).to.equal(`Test Series/${oldTitle}`)
|
||||||
|
|
||||||
|
const updatedBook = await Database.bookModel.findByPk(book.id)
|
||||||
|
expect(updatedBook.title).to.equal(oldTitle)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates placeholder folder when metadata is stored with items and original path is missing', async () => {
|
it('creates placeholder folder when metadata is stored with items and original path is missing', async () => {
|
||||||
|
|
@ -373,12 +384,14 @@ describe('LibraryItemController', () => {
|
||||||
tempPaths.push(tempRoot)
|
tempPaths.push(tempRoot)
|
||||||
|
|
||||||
const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id })
|
const libraryFolder = await Database.libraryFolderModel.create({ path: filePathToPOSIX(tempRoot), libraryId: library.id })
|
||||||
|
const series = await Database.seriesModel.create({ name: 'Test Series', libraryId: library.id })
|
||||||
const oldTitle = 'Old Title'
|
const oldTitle = 'Old Title'
|
||||||
const newTitle = 'New Title'
|
const newTitle = 'New Title'
|
||||||
const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series'))
|
const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series'))
|
||||||
const oldPath = filePathToPOSIX(Path.join(seriesDir, oldTitle))
|
const oldPath = filePathToPOSIX(Path.join(seriesDir, oldTitle))
|
||||||
|
|
||||||
const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
const book = await Database.bookModel.create({ title: oldTitle, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id })
|
||||||
const libraryItem = await Database.libraryItemModel.create({
|
const libraryItem = await Database.libraryItemModel.create({
|
||||||
libraryFiles: [],
|
libraryFiles: [],
|
||||||
mediaId: book.id,
|
mediaId: book.id,
|
||||||
|
|
@ -415,5 +428,134 @@ describe('LibraryItemController', () => {
|
||||||
const newPathExists = await fs.pathExists(expectedPath)
|
const newPathExists = await fs.pathExists(expectedPath)
|
||||||
expect(newPathExists).to.be.true
|
expect(newPathExists).to.be.true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('moves placeholder folder when series 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 oldSeries = await Database.seriesModel.create({ name: 'Series A', libraryId: library.id })
|
||||||
|
const newSeries = await Database.seriesModel.create({ name: 'Series B', libraryId: library.id })
|
||||||
|
|
||||||
|
const title = 'Placeholder'
|
||||||
|
const oldPath = filePathToPOSIX(Path.join(libraryFolder.path, 'Author A', oldSeries.name, title))
|
||||||
|
await fs.ensureDir(oldPath)
|
||||||
|
|
||||||
|
const book = await Database.bookModel.create({ title, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
await Database.bookSeriesModel.create({ bookId: book.id, seriesId: oldSeries.id, sequence: '1' })
|
||||||
|
|
||||||
|
const libraryItem = await Database.libraryItemModel.create({
|
||||||
|
libraryFiles: [],
|
||||||
|
mediaId: book.id,
|
||||||
|
mediaType: 'book',
|
||||||
|
libraryId: library.id,
|
||||||
|
libraryFolderId: libraryFolder.id,
|
||||||
|
path: oldPath,
|
||||||
|
relPath: `Author A/${oldSeries.name}/${title}`,
|
||||||
|
authorNamesFirstLast: 'Author A',
|
||||||
|
authorNamesLastFirst: 'A, Author',
|
||||||
|
isFile: false,
|
||||||
|
isPlaceholder: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id)
|
||||||
|
expandedItem.saveMetadataFile = sinon.stub().resolves()
|
||||||
|
|
||||||
|
const fakeReq = {
|
||||||
|
query: {},
|
||||||
|
body: {
|
||||||
|
metadata: {
|
||||||
|
series: [{ id: newSeries.id, name: newSeries.name }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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(libraryFolder.path, 'Author A', newSeries.name, title))
|
||||||
|
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(`Author A/${newSeries.name}/${title}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves series-origin placeholder folder without inferring author directory from relPath', 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 oldSeries = await Database.seriesModel.create({ name: 'Series A', libraryId: library.id })
|
||||||
|
const newSeries = await Database.seriesModel.create({ name: 'Series B', libraryId: library.id })
|
||||||
|
|
||||||
|
const title = 'Placeholder'
|
||||||
|
const oldPath = filePathToPOSIX(Path.join(libraryFolder.path, oldSeries.name, title))
|
||||||
|
await fs.ensureDir(oldPath)
|
||||||
|
|
||||||
|
const book = await Database.bookModel.create({ title, audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||||
|
await Database.bookSeriesModel.create({ bookId: book.id, seriesId: oldSeries.id, sequence: '1' })
|
||||||
|
|
||||||
|
const libraryItem = await Database.libraryItemModel.create({
|
||||||
|
libraryFiles: [],
|
||||||
|
mediaId: book.id,
|
||||||
|
mediaType: 'book',
|
||||||
|
libraryId: library.id,
|
||||||
|
libraryFolderId: libraryFolder.id,
|
||||||
|
path: oldPath,
|
||||||
|
relPath: `${oldSeries.name}/${title}`,
|
||||||
|
authorNamesFirstLast: '',
|
||||||
|
authorNamesLastFirst: '',
|
||||||
|
isFile: false,
|
||||||
|
isPlaceholder: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const expandedItem = await Database.libraryItemModel.getExpandedById(libraryItem.id)
|
||||||
|
expandedItem.saveMetadataFile = sinon.stub().resolves()
|
||||||
|
|
||||||
|
const fakeReq = {
|
||||||
|
query: {},
|
||||||
|
body: {
|
||||||
|
metadata: {
|
||||||
|
series: [{ id: newSeries.id, name: newSeries.name }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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(libraryFolder.path, newSeries.name, title))
|
||||||
|
const invalidPath = filePathToPOSIX(Path.join(libraryFolder.path, oldSeries.name, newSeries.name, title))
|
||||||
|
const oldPathExists = await fs.pathExists(oldPath)
|
||||||
|
const newPathExists = await fs.pathExists(expectedPath)
|
||||||
|
const invalidPathExists = await fs.pathExists(invalidPath)
|
||||||
|
expect(oldPathExists).to.be.false
|
||||||
|
expect(newPathExists).to.be.true
|
||||||
|
expect(invalidPathExists).to.be.false
|
||||||
|
|
||||||
|
const updatedItem = await Database.libraryItemModel.findByPk(libraryItem.id)
|
||||||
|
expect(updatedItem.path).to.equal(expectedPath)
|
||||||
|
expect(updatedItem.relPath).to.equal(`${newSeries.name}/${title}`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
256
test/server/scanner/LibraryScanner.placeholders.test.js
Normal file
256
test/server/scanner/LibraryScanner.placeholders.test.js
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
const chai = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
|
||||||
|
const LibraryScanner = require('../../../server/scanner/LibraryScanner')
|
||||||
|
const LibraryScan = require('../../../server/scanner/LibraryScan')
|
||||||
|
const LibraryItemScanner = require('../../../server/scanner/LibraryItemScanner')
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
const libraryFilters = require('../../../server/utils/queries/libraryFilters')
|
||||||
|
const SocketAuthority = require('../../../server/SocketAuthority')
|
||||||
|
const fs = require('../../../server/libs/fsExtra')
|
||||||
|
const { ScanResult } = require('../../../server/utils/constants')
|
||||||
|
|
||||||
|
const expect = chai.expect
|
||||||
|
|
||||||
|
describe('LibraryScanner placeholder handling', () => {
|
||||||
|
let originalSequelize
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalSequelize = Database.sequelize
|
||||||
|
Database.sequelize = {
|
||||||
|
models: {
|
||||||
|
libraryItem: {
|
||||||
|
findAll: () => {},
|
||||||
|
getExpandedById: () => {},
|
||||||
|
update: () => {},
|
||||||
|
findOneExpanded: () => {},
|
||||||
|
findOne: () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Database.sequelize = originalSequelize
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips missing checks for placeholder items during full scans', async () => {
|
||||||
|
const library = {
|
||||||
|
id: 'library-1',
|
||||||
|
name: 'Test Library',
|
||||||
|
mediaType: 'book',
|
||||||
|
settings: {},
|
||||||
|
libraryFolders: [
|
||||||
|
{
|
||||||
|
id: 'folder-1',
|
||||||
|
path: '/library'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryScan = new LibraryScan()
|
||||||
|
libraryScan.setData(library)
|
||||||
|
|
||||||
|
const placeholderItem = {
|
||||||
|
id: 'item-1',
|
||||||
|
path: '/library/Series/Placeholder',
|
||||||
|
relPath: 'Series/Placeholder',
|
||||||
|
ino: 123,
|
||||||
|
isPlaceholder: true,
|
||||||
|
isMissing: false,
|
||||||
|
libraryFiles: [],
|
||||||
|
save: sinon.stub().resolves(),
|
||||||
|
changed: sinon.stub()
|
||||||
|
}
|
||||||
|
|
||||||
|
sinon.stub(libraryFilters, 'getFilterData').resolves()
|
||||||
|
sinon.stub(LibraryScanner, 'scanFolder').resolves([])
|
||||||
|
const findAllStub = sinon.stub(Database.libraryItemModel, 'findAll').resolves([placeholderItem])
|
||||||
|
const getExpandedStub = sinon.stub(Database.libraryItemModel, 'getExpandedById').resolves(null)
|
||||||
|
const updateStub = sinon.stub(Database.libraryItemModel, 'update').resolves()
|
||||||
|
const checkAuthorsStub = sinon.stub(LibraryItemScanner, 'checkAuthorsAndSeriesRemovedFromBooks').resolves()
|
||||||
|
|
||||||
|
await LibraryScanner.scanLibrary(libraryScan, false)
|
||||||
|
|
||||||
|
expect(findAllStub.calledOnce).to.be.true
|
||||||
|
expect(getExpandedStub.called).to.be.false
|
||||||
|
expect(updateStub.called).to.be.false
|
||||||
|
expect(checkAuthorsStub.calledOnce).to.be.true
|
||||||
|
expect(libraryScan.resultsMissing).to.equal(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('promotes placeholders when audio files are detected in folder updates', async () => {
|
||||||
|
const library = {
|
||||||
|
id: 'library-1',
|
||||||
|
name: 'Test Library',
|
||||||
|
mediaType: 'book',
|
||||||
|
settings: {}
|
||||||
|
}
|
||||||
|
const folder = {
|
||||||
|
id: 'folder-1',
|
||||||
|
path: '/library',
|
||||||
|
libraryId: library.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholderItem = {
|
||||||
|
id: 'item-1',
|
||||||
|
path: '/library/Series/Placeholder',
|
||||||
|
relPath: 'Series/Placeholder',
|
||||||
|
isPlaceholder: true,
|
||||||
|
isMissing: false,
|
||||||
|
media: { title: 'Placeholder Book' },
|
||||||
|
save: sinon.stub().resolves(),
|
||||||
|
changed: sinon.stub()
|
||||||
|
}
|
||||||
|
|
||||||
|
sinon.stub(libraryFilters, 'getFilterData').resolves()
|
||||||
|
sinon.stub(Database.libraryItemModel, 'findOneExpanded').resolves(placeholderItem)
|
||||||
|
sinon.stub(Database.libraryItemModel, 'findOne').resolves(null)
|
||||||
|
sinon.stub(fs, 'pathExists').resolves(true)
|
||||||
|
const scanLibraryItemStub = sinon.stub(LibraryItemScanner, 'scanLibraryItem').resolves(ScanResult.UPDATED)
|
||||||
|
sinon.stub(SocketAuthority, 'libraryItemEmitter')
|
||||||
|
|
||||||
|
const fileUpdateGroup = {
|
||||||
|
'Series/Placeholder': ['track-01.mp3']
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await LibraryScanner.scanFolderUpdates(library, folder, fileUpdateGroup)
|
||||||
|
|
||||||
|
expect(placeholderItem.isPlaceholder).to.be.false
|
||||||
|
expect(placeholderItem.save.calledOnce).to.be.true
|
||||||
|
expect(scanLibraryItemStub.calledOnce).to.be.true
|
||||||
|
expect(results['Series/Placeholder']).to.equal(ScanResult.UPDATED)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('promotes placeholders during full scans when media is present', async () => {
|
||||||
|
const library = {
|
||||||
|
id: 'library-1',
|
||||||
|
name: 'Test Library',
|
||||||
|
mediaType: 'book',
|
||||||
|
settings: {},
|
||||||
|
libraryFolders: [
|
||||||
|
{
|
||||||
|
id: 'folder-1',
|
||||||
|
path: '/library'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryScan = new LibraryScan()
|
||||||
|
libraryScan.setData(library)
|
||||||
|
|
||||||
|
const placeholderItem = {
|
||||||
|
id: 'item-1',
|
||||||
|
path: '/library/Series/Placeholder',
|
||||||
|
relPath: 'Series/Placeholder',
|
||||||
|
isPlaceholder: true,
|
||||||
|
isMissing: false,
|
||||||
|
save: sinon.stub().resolves(),
|
||||||
|
changed: sinon.stub()
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryItemData = {
|
||||||
|
path: placeholderItem.path,
|
||||||
|
audioLibraryFiles: [{ metadata: { ext: '.mp3' } }],
|
||||||
|
ebookLibraryFiles: [],
|
||||||
|
hasLibraryFileChanges: true,
|
||||||
|
hasPathChange: false,
|
||||||
|
checkLibraryItemData: sinon.stub().resolves(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
sinon.stub(libraryFilters, 'getFilterData').resolves()
|
||||||
|
sinon.stub(LibraryScanner, 'scanFolder').resolves([libraryItemData])
|
||||||
|
sinon.stub(Database.libraryItemModel, 'findAll').resolves([placeholderItem])
|
||||||
|
sinon.stub(LibraryItemScanner, 'rescanLibraryItemMedia').resolves({ libraryItem: placeholderItem, wasUpdated: true })
|
||||||
|
sinon.stub(LibraryItemScanner, 'checkAuthorsAndSeriesRemovedFromBooks').resolves()
|
||||||
|
sinon.stub(SocketAuthority, 'libraryItemsEmitter')
|
||||||
|
|
||||||
|
await LibraryScanner.scanLibrary(libraryScan, false)
|
||||||
|
|
||||||
|
expect(placeholderItem.isPlaceholder).to.be.false
|
||||||
|
expect(placeholderItem.save.calledOnce).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips placeholder scans on file updates without audio', async () => {
|
||||||
|
const library = {
|
||||||
|
id: 'library-1',
|
||||||
|
name: 'Test Library',
|
||||||
|
mediaType: 'book',
|
||||||
|
settings: {}
|
||||||
|
}
|
||||||
|
const folder = {
|
||||||
|
id: 'folder-1',
|
||||||
|
path: '/library',
|
||||||
|
libraryId: library.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholderItem = {
|
||||||
|
id: 'item-1',
|
||||||
|
path: '/library/Series/Placeholder',
|
||||||
|
relPath: 'Series/Placeholder',
|
||||||
|
isPlaceholder: true,
|
||||||
|
isMissing: false,
|
||||||
|
media: { title: 'Placeholder Book' },
|
||||||
|
save: sinon.stub().resolves(),
|
||||||
|
changed: sinon.stub()
|
||||||
|
}
|
||||||
|
|
||||||
|
sinon.stub(libraryFilters, 'getFilterData').resolves()
|
||||||
|
sinon.stub(Database.libraryItemModel, 'findOneExpanded').resolves(placeholderItem)
|
||||||
|
sinon.stub(Database.libraryItemModel, 'findOne').resolves(null)
|
||||||
|
sinon.stub(fs, 'pathExists').resolves(true)
|
||||||
|
const scanLibraryItemStub = sinon.stub(LibraryItemScanner, 'scanLibraryItem').resolves(ScanResult.UPDATED)
|
||||||
|
sinon.stub(SocketAuthority, 'libraryItemEmitter')
|
||||||
|
|
||||||
|
const fileUpdateGroup = {
|
||||||
|
'Series/Placeholder': ['cover.jpg']
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await LibraryScanner.scanFolderUpdates(library, folder, fileUpdateGroup)
|
||||||
|
|
||||||
|
expect(scanLibraryItemStub.called).to.be.false
|
||||||
|
expect(results['Series/Placeholder']).to.equal(ScanResult.NOTHING)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores missing checks for placeholders on file updates when path is gone', async () => {
|
||||||
|
const library = {
|
||||||
|
id: 'library-1',
|
||||||
|
name: 'Test Library',
|
||||||
|
mediaType: 'book',
|
||||||
|
settings: {}
|
||||||
|
}
|
||||||
|
const folder = {
|
||||||
|
id: 'folder-1',
|
||||||
|
path: '/library',
|
||||||
|
libraryId: library.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholderItem = {
|
||||||
|
id: 'item-1',
|
||||||
|
path: '/library/Series/Placeholder',
|
||||||
|
relPath: 'Series/Placeholder',
|
||||||
|
isPlaceholder: true,
|
||||||
|
isMissing: false,
|
||||||
|
media: { title: 'Placeholder Book' },
|
||||||
|
save: sinon.stub().resolves(),
|
||||||
|
changed: sinon.stub()
|
||||||
|
}
|
||||||
|
|
||||||
|
sinon.stub(libraryFilters, 'getFilterData').resolves()
|
||||||
|
sinon.stub(Database.libraryItemModel, 'findOneExpanded').resolves(placeholderItem)
|
||||||
|
sinon.stub(Database.libraryItemModel, 'findOne').resolves(null)
|
||||||
|
sinon.stub(fs, 'pathExists').resolves(false)
|
||||||
|
const scanLibraryItemStub = sinon.stub(LibraryItemScanner, 'scanLibraryItem').resolves(ScanResult.UPDATED)
|
||||||
|
sinon.stub(SocketAuthority, 'libraryItemEmitter')
|
||||||
|
|
||||||
|
const fileUpdateGroup = {
|
||||||
|
'Series/Placeholder': ['track-01.mp3']
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await LibraryScanner.scanFolderUpdates(library, folder, fileUpdateGroup)
|
||||||
|
|
||||||
|
expect(scanLibraryItemStub.called).to.be.false
|
||||||
|
expect(results['Series/Placeholder']).to.equal(ScanResult.NOTHING)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
|
||||||
|
const Database = require('../../../../server/Database')
|
||||||
|
const libraryFilters = require('../../../../server/utils/queries/libraryFilters')
|
||||||
|
|
||||||
|
describe('libraryFilters placeholders', () => {
|
||||||
|
let library
|
||||||
|
let libraryFolder
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
|
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||||
|
global.ServerSettings = { sortingPrefixes: [] }
|
||||||
|
await Database.buildModels()
|
||||||
|
Database.libraryFilterData = {}
|
||||||
|
|
||||||
|
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||||
|
libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Database.sequelize.sync({ force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const createBookWithItem = async ({ title, tags, genres, narrators, publisher, publishedYear, language, isPlaceholder, isMissing }) => {
|
||||||
|
const book = await Database.bookModel.create({
|
||||||
|
title,
|
||||||
|
audioFiles: [],
|
||||||
|
tags,
|
||||||
|
narrators,
|
||||||
|
genres,
|
||||||
|
publisher,
|
||||||
|
publishedYear,
|
||||||
|
language,
|
||||||
|
chapters: []
|
||||||
|
})
|
||||||
|
|
||||||
|
await Database.libraryItemModel.create({
|
||||||
|
libraryFiles: [],
|
||||||
|
mediaId: book.id,
|
||||||
|
mediaType: 'book',
|
||||||
|
libraryId: library.id,
|
||||||
|
libraryFolderId: libraryFolder.id,
|
||||||
|
isPlaceholder,
|
||||||
|
isMissing
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('skips placeholder items when aggregating filter data', async () => {
|
||||||
|
await createBookWithItem({
|
||||||
|
title: 'Real Book',
|
||||||
|
tags: ['real-tag'],
|
||||||
|
genres: ['real-genre'],
|
||||||
|
narrators: ['real-narrator'],
|
||||||
|
publisher: 'Real Publisher',
|
||||||
|
publishedYear: 2001,
|
||||||
|
language: 'en',
|
||||||
|
isPlaceholder: false,
|
||||||
|
isMissing: false
|
||||||
|
})
|
||||||
|
await createBookWithItem({
|
||||||
|
title: 'Placeholder Book',
|
||||||
|
tags: ['placeholder-tag'],
|
||||||
|
genres: ['placeholder-genre'],
|
||||||
|
narrators: ['placeholder-narrator'],
|
||||||
|
publisher: 'Placeholder Publisher',
|
||||||
|
publishedYear: 1999,
|
||||||
|
language: 'fr',
|
||||||
|
isPlaceholder: true,
|
||||||
|
isMissing: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await libraryFilters.getFilterData('book', library.id)
|
||||||
|
|
||||||
|
expect(data.bookCount).to.equal(1)
|
||||||
|
expect(data.numIssues).to.equal(0)
|
||||||
|
expect(data.tags).to.include('real-tag')
|
||||||
|
expect(data.tags).to.not.include('placeholder-tag')
|
||||||
|
expect(data.genres).to.include('real-genre')
|
||||||
|
expect(data.genres).to.not.include('placeholder-genre')
|
||||||
|
expect(data.narrators).to.include('real-narrator')
|
||||||
|
expect(data.narrators).to.not.include('placeholder-narrator')
|
||||||
|
expect(data.publishers).to.include('Real Publisher')
|
||||||
|
expect(data.publishers).to.not.include('Placeholder Publisher')
|
||||||
|
expect(data.languages).to.include('en')
|
||||||
|
expect(data.languages).to.not.include('fr')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
|
||||||
|
const Database = require('../../../../server/Database')
|
||||||
|
const libraryItemFilters = require('../../../../server/utils/queries/libraryItemFilters')
|
||||||
|
|
||||||
|
describe('libraryItemFilters placeholders', () => {
|
||||||
|
let library
|
||||||
|
let libraryFolder
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
|
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||||
|
global.ServerSettings = { sortingPrefixes: [] }
|
||||||
|
await Database.buildModels()
|
||||||
|
|
||||||
|
library = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
|
||||||
|
libraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: library.id })
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Database.sequelize.sync({ force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const createBookWithItem = async ({ title, tags = [], genres = [], narrators = [], isPlaceholder = false }) => {
|
||||||
|
const book = await Database.bookModel.create({
|
||||||
|
title,
|
||||||
|
audioFiles: [],
|
||||||
|
tags,
|
||||||
|
narrators,
|
||||||
|
genres,
|
||||||
|
chapters: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const libraryItem = await Database.libraryItemModel.create({
|
||||||
|
libraryFiles: [],
|
||||||
|
mediaId: book.id,
|
||||||
|
mediaType: 'book',
|
||||||
|
libraryId: library.id,
|
||||||
|
libraryFolderId: libraryFolder.id,
|
||||||
|
isPlaceholder
|
||||||
|
})
|
||||||
|
|
||||||
|
return { book, libraryItem }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('excludes placeholders when filtering by tags', async () => {
|
||||||
|
const realItem = await createBookWithItem({ title: 'Real Book', tags: ['real-tag'], isPlaceholder: false })
|
||||||
|
await createBookWithItem({ title: 'Placeholder Book', tags: ['placeholder-tag'], isPlaceholder: true })
|
||||||
|
|
||||||
|
const items = await libraryItemFilters.getAllLibraryItemsWithTags(['real-tag', 'placeholder-tag'])
|
||||||
|
|
||||||
|
expect(items).to.have.length(1)
|
||||||
|
expect(items[0].id).to.equal(realItem.libraryItem.id)
|
||||||
|
expect(items[0].isPlaceholder).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes placeholders when filtering by genres', async () => {
|
||||||
|
const realItem = await createBookWithItem({ title: 'Real Book', genres: ['real-genre'], isPlaceholder: false })
|
||||||
|
await createBookWithItem({ title: 'Placeholder Book', genres: ['placeholder-genre'], isPlaceholder: true })
|
||||||
|
|
||||||
|
const items = await libraryItemFilters.getAllLibraryItemsWithGenres(['real-genre', 'placeholder-genre'])
|
||||||
|
|
||||||
|
expect(items).to.have.length(1)
|
||||||
|
expect(items[0].id).to.equal(realItem.libraryItem.id)
|
||||||
|
expect(items[0].isPlaceholder).to.be.false
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes placeholders when filtering by narrators', async () => {
|
||||||
|
const realItem = await createBookWithItem({ title: 'Real Book', narrators: ['real-narrator'], isPlaceholder: false })
|
||||||
|
await createBookWithItem({ title: 'Placeholder Book', narrators: ['placeholder-narrator'], isPlaceholder: true })
|
||||||
|
|
||||||
|
const items = await libraryItemFilters.getAllLibraryItemsWithNarrators(['real-narrator', 'placeholder-narrator'])
|
||||||
|
|
||||||
|
expect(items).to.have.length(1)
|
||||||
|
expect(items[0].id).to.equal(realItem.libraryItem.id)
|
||||||
|
expect(items[0].isPlaceholder).to.be.false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -3,6 +3,7 @@ const { Sequelize } = require('sequelize')
|
||||||
|
|
||||||
const Database = require('../../../../server/Database')
|
const Database = require('../../../../server/Database')
|
||||||
const libraryItemsBookFilters = require('../../../../server/utils/queries/libraryItemsBookFilters')
|
const libraryItemsBookFilters = require('../../../../server/utils/queries/libraryItemsBookFilters')
|
||||||
|
const libraryFilters = require('../../../../server/utils/queries/libraryFilters')
|
||||||
const User = require('../../../../server/models/User')
|
const User = require('../../../../server/models/User')
|
||||||
|
|
||||||
describe('libraryItemsBookFilters placeholders', () => {
|
describe('libraryItemsBookFilters placeholders', () => {
|
||||||
|
|
@ -41,7 +42,7 @@ describe('libraryItemsBookFilters placeholders', () => {
|
||||||
chapters: []
|
chapters: []
|
||||||
})
|
})
|
||||||
|
|
||||||
return Database.libraryItemModel.create({
|
const libraryItem = await Database.libraryItemModel.create({
|
||||||
libraryFiles: [],
|
libraryFiles: [],
|
||||||
mediaId: book.id,
|
mediaId: book.id,
|
||||||
mediaType: 'book',
|
mediaType: 'book',
|
||||||
|
|
@ -49,6 +50,35 @@ describe('libraryItemsBookFilters placeholders', () => {
|
||||||
libraryFolderId: libraryFolder.id,
|
libraryFolderId: libraryFolder.id,
|
||||||
isPlaceholder
|
isPlaceholder
|
||||||
})
|
})
|
||||||
|
|
||||||
|
return { book, libraryItem }
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSeriesWithBook = async ({ seriesName, title, isPlaceholder }) => {
|
||||||
|
const series = await Database.seriesModel.create({
|
||||||
|
name: seriesName,
|
||||||
|
libraryId: library.id
|
||||||
|
})
|
||||||
|
const { book, libraryItem } = await createBookWithItem({ title, isPlaceholder })
|
||||||
|
await Database.bookSeriesModel.create({
|
||||||
|
seriesId: series.id,
|
||||||
|
bookId: book.id,
|
||||||
|
sequence: '1'
|
||||||
|
})
|
||||||
|
return { series, book, libraryItem }
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAuthorWithBook = async ({ authorName, title, isPlaceholder }) => {
|
||||||
|
const author = await Database.authorModel.create({
|
||||||
|
name: authorName,
|
||||||
|
libraryId: library.id
|
||||||
|
})
|
||||||
|
const { book, libraryItem } = await createBookWithItem({ title, isPlaceholder })
|
||||||
|
await Database.bookAuthorModel.create({
|
||||||
|
authorId: author.id,
|
||||||
|
bookId: book.id
|
||||||
|
})
|
||||||
|
return { author, book, libraryItem }
|
||||||
}
|
}
|
||||||
|
|
||||||
it('excludes placeholders from general library list queries', async () => {
|
it('excludes placeholders from general library list queries', async () => {
|
||||||
|
|
@ -81,4 +111,28 @@ describe('libraryItemsBookFilters placeholders', () => {
|
||||||
|
|
||||||
expect(results.book).to.have.length(0)
|
expect(results.book).to.have.length(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('includes placeholders when fetching items for a series', async () => {
|
||||||
|
const real = await createSeriesWithBook({ seriesName: 'Series A', title: 'Real Book', isPlaceholder: false })
|
||||||
|
await createBookWithItem({ title: 'Standalone Book', isPlaceholder: false })
|
||||||
|
await createSeriesWithBook({ seriesName: 'Series A', title: 'Placeholder Book', isPlaceholder: true })
|
||||||
|
|
||||||
|
const items = await libraryItemsBookFilters.getLibraryItemsForSeries(real.series, user)
|
||||||
|
|
||||||
|
expect(items).to.have.length(2)
|
||||||
|
const titles = items.map((item) => item.media.title).sort()
|
||||||
|
expect(titles).to.deep.equal(['Placeholder Book', 'Real Book'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes placeholders when fetching items for an author', async () => {
|
||||||
|
const real = await createAuthorWithBook({ authorName: 'Author A', title: 'Real Book', isPlaceholder: false })
|
||||||
|
await createBookWithItem({ title: 'Standalone Book', isPlaceholder: false })
|
||||||
|
await createAuthorWithBook({ authorName: 'Author A', title: 'Placeholder Book', isPlaceholder: true })
|
||||||
|
|
||||||
|
const { libraryItems, count } = await libraryFilters.getLibraryItemsForAuthor(real.author, user, 20, 0)
|
||||||
|
|
||||||
|
expect(count).to.equal(2)
|
||||||
|
const titles = libraryItems.map((item) => item.media.title).sort()
|
||||||
|
expect(titles).to.deep.equal(['Placeholder Book', 'Real Book'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue