mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 09:51:37 +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">
|
||||
<span class="material-symbols text-base">edit</span>
|
||||
</button>
|
||||
|
||||
<ui-btn v-if="userCanUpdate" :loading="processingPlaceholder" color="bg-primary" small @click="createAuthorPlaceholder">{{ $strings.ButtonAddPlaceholder }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<p v-if="author.description" class="text-white/60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p>
|
||||
|
|
@ -67,7 +69,8 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
isDescriptionClamped: false,
|
||||
showFullDescription: false
|
||||
showFullDescription: false,
|
||||
processingPlaceholder: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -95,6 +98,25 @@ export default {
|
|||
editAuthor() {
|
||||
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) {
|
||||
if (author.id === this.author.id) {
|
||||
console.log('Author was updated', author)
|
||||
|
|
|
|||
|
|
@ -300,3 +300,43 @@ paths:
|
|||
- $ref: '#/components/schemas/authorUpdated'
|
||||
'404':
|
||||
$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'
|
||||
/api/authors/{id}/match:
|
||||
$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:
|
||||
$ref: './controllers/EmailController.yaml#/paths/~1api~1emails~1settings'
|
||||
/api/emails/test:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
const Path = require('path')
|
||||
const { Request, Response, NextFunction } = require('express')
|
||||
const sequelize = require('sequelize')
|
||||
const fs = require('../libs/fsExtra')
|
||||
|
|
@ -9,6 +10,8 @@ const Database = require('../Database')
|
|||
const CacheManager = require('../managers/CacheManager')
|
||||
const CoverManager = require('../managers/CoverManager')
|
||||
const AuthorFinder = require('../finders/AuthorFinder')
|
||||
const { getTitleIgnorePrefix } = require('../utils')
|
||||
const { sanitizeFilename, filePathToPOSIX } = require('../utils/fileUtils')
|
||||
|
||||
const { reqSupportsWebp, isValidASIN } = require('../utils/index')
|
||||
|
||||
|
|
@ -256,6 +259,186 @@ class AuthorController {
|
|||
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
|
||||
* Upload author image from web URL
|
||||
|
|
|
|||
|
|
@ -193,6 +193,43 @@ class LibraryItemController {
|
|||
const originalPath = req.libraryItem.path
|
||||
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) {
|
||||
await LibraryItemController.prototype.uploadCover.bind(this)(req, res, false)
|
||||
if (res.writableEnded || res.headersSent) return
|
||||
|
|
@ -246,18 +283,33 @@ class LibraryItemController {
|
|||
}
|
||||
}
|
||||
|
||||
if (
|
||||
wasPlaceholder &&
|
||||
typeof req.libraryItem.media?.title === 'string' &&
|
||||
req.libraryItem.media.title &&
|
||||
req.libraryItem.media.title !== originalTitle
|
||||
) {
|
||||
const placeholderTitleUpdated = wasPlaceholder && typeof req.libraryItem.media?.title === 'string' && req.libraryItem.media.title && req.libraryItem.media.title !== originalTitle
|
||||
const placeholderSeriesUpdated = wasPlaceholder && req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)
|
||||
|
||||
if ((placeholderTitleUpdated || placeholderSeriesUpdated) && !req.libraryItem.isFile) {
|
||||
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))
|
||||
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 (await fs.pathExists(updatedPath)) {
|
||||
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) {
|
||||
let relPath = updatedPath.replace(filePathToPOSIX(libraryFolder.path), '')
|
||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ class ApiRouter {
|
|||
// Author Routes
|
||||
//
|
||||
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.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))
|
||||
|
|
|
|||
|
|
@ -183,6 +183,8 @@ class LibraryScanner {
|
|||
// Podcast folder can have no episodes and still be valid
|
||||
if (libraryScan.libraryMediaType === 'podcast' && (await fs.pathExists(existingLibraryItem.path))) {
|
||||
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 {
|
||||
libraryScan.addLog(LogLevel.WARN, `Library Item "${existingLibraryItem.path}" (inode: ${existingLibraryItem.ino}) is missing`)
|
||||
libraryScan.resultsMissing++
|
||||
|
|
@ -201,6 +203,21 @@ class LibraryScanner {
|
|||
} else {
|
||||
libraryItemDataFound = libraryItemDataFound.filter((lidf) => lidf !== libraryItemData)
|
||||
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 (forceRescan || libraryItemData.hasLibraryFileChanges || libraryItemData.hasPathChange) {
|
||||
const { libraryItem, wasUpdated } = await LibraryItemScanner.rescanLibraryItemMedia(existingLibraryItem, libraryItemData, libraryScan.library.settings, libraryScan)
|
||||
|
|
@ -590,6 +607,11 @@ class LibraryScanner {
|
|||
if (existingLibraryItem.path === fullPath) {
|
||||
const exists = await fs.pathExists(fullPath)
|
||||
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`)
|
||||
existingLibraryItem.isMissing = true
|
||||
await existingLibraryItem.save()
|
||||
|
|
@ -599,6 +621,15 @@ class LibraryScanner {
|
|||
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
|
||||
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)
|
||||
|
|
@ -639,6 +670,36 @@ class LibraryScanner {
|
|||
|
||||
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -553,7 +553,8 @@ module.exports = {
|
|||
model: Database.libraryItemModel,
|
||||
attributes: [],
|
||||
where: {
|
||||
libraryId: libraryId
|
||||
libraryId: libraryId,
|
||||
isPlaceholder: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -582,6 +583,7 @@ module.exports = {
|
|||
attributes: [],
|
||||
where: {
|
||||
libraryId: libraryId,
|
||||
isPlaceholder: false,
|
||||
updatedAt: {
|
||||
[Sequelize.Op.gt]: new Date(lastLoadedAt)
|
||||
}
|
||||
|
|
@ -636,7 +638,8 @@ module.exports = {
|
|||
model: Database.libraryItemModel,
|
||||
attributes: ['isMissing', 'isInvalid'],
|
||||
where: {
|
||||
libraryId: libraryId
|
||||
libraryId: libraryId,
|
||||
isPlaceholder: false
|
||||
}
|
||||
},
|
||||
attributes: ['tags', 'genres', 'publisher', 'publishedYear', 'narrators', 'language']
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ module.exports = {
|
|||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
model: Database.libraryItemModel,
|
||||
where: {
|
||||
isPlaceholder: false
|
||||
}
|
||||
},
|
||||
{
|
||||
model: Database.authorModel,
|
||||
|
|
@ -54,7 +57,10 @@ module.exports = {
|
|||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
model: Database.libraryItemModel,
|
||||
where: {
|
||||
isPlaceholder: false
|
||||
}
|
||||
},
|
||||
{
|
||||
model: Database.podcastEpisodeModel
|
||||
|
|
@ -85,7 +91,10 @@ module.exports = {
|
|||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
model: Database.libraryItemModel,
|
||||
where: {
|
||||
isPlaceholder: false
|
||||
}
|
||||
},
|
||||
{
|
||||
model: Database.authorModel,
|
||||
|
|
@ -115,7 +124,10 @@ module.exports = {
|
|||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
model: Database.libraryItemModel,
|
||||
where: {
|
||||
isPlaceholder: false
|
||||
}
|
||||
},
|
||||
{
|
||||
model: Database.podcastEpisodeModel
|
||||
|
|
@ -146,7 +158,10 @@ module.exports = {
|
|||
},
|
||||
include: [
|
||||
{
|
||||
model: Database.libraryItemModel
|
||||
model: Database.libraryItemModel,
|
||||
where: {
|
||||
isPlaceholder: false
|
||||
}
|
||||
},
|
||||
{
|
||||
model: Database.authorModel,
|
||||
|
|
|
|||
|
|
@ -408,8 +408,10 @@ module.exports = {
|
|||
|
||||
let bookAttributes = null
|
||||
|
||||
const allowPlaceholderItems = filterGroup === 'series' || filterGroup === 'authors'
|
||||
const libraryItemWhere = {
|
||||
libraryId
|
||||
libraryId,
|
||||
...(allowPlaceholderItems ? {} : { isPlaceholder: false })
|
||||
}
|
||||
|
||||
let seriesInclude = {
|
||||
|
|
@ -1079,7 +1081,8 @@ module.exports = {
|
|||
{
|
||||
model: Database.libraryItemModel,
|
||||
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)
|
||||
|
||||
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 newTitle = 'New Title'
|
||||
const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series'))
|
||||
|
|
@ -223,6 +224,7 @@ describe('LibraryItemController', () => {
|
|||
await fs.ensureDir(oldPath)
|
||||
|
||||
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({
|
||||
libraryFiles: [],
|
||||
mediaId: book.id,
|
||||
|
|
@ -321,6 +323,7 @@ describe('LibraryItemController', () => {
|
|||
tempPaths.push(tempRoot)
|
||||
|
||||
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 newTitle = 'New Title'
|
||||
const seriesDir = filePathToPOSIX(Path.join(libraryFolder.path, 'Test Series'))
|
||||
|
|
@ -330,6 +333,7 @@ describe('LibraryItemController', () => {
|
|||
await fs.ensureDir(newPath)
|
||||
|
||||
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({
|
||||
libraryFiles: [],
|
||||
mediaId: book.id,
|
||||
|
|
@ -364,6 +368,13 @@ describe('LibraryItemController', () => {
|
|||
|
||||
expect(fakeRes.status.calledWith(400)).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 () => {
|
||||
|
|
@ -373,12 +384,14 @@ describe('LibraryItemController', () => {
|
|||
tempPaths.push(tempRoot)
|
||||
|
||||
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 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: [] })
|
||||
await Database.bookSeriesModel.create({ bookId: book.id, seriesId: series.id })
|
||||
const libraryItem = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book.id,
|
||||
|
|
@ -415,5 +428,134 @@ describe('LibraryItemController', () => {
|
|||
const newPathExists = await fs.pathExists(expectedPath)
|
||||
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 libraryItemsBookFilters = require('../../../../server/utils/queries/libraryItemsBookFilters')
|
||||
const libraryFilters = require('../../../../server/utils/queries/libraryFilters')
|
||||
const User = require('../../../../server/models/User')
|
||||
|
||||
describe('libraryItemsBookFilters placeholders', () => {
|
||||
|
|
@ -41,7 +42,7 @@ describe('libraryItemsBookFilters placeholders', () => {
|
|||
chapters: []
|
||||
})
|
||||
|
||||
return Database.libraryItemModel.create({
|
||||
const libraryItem = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book.id,
|
||||
mediaType: 'book',
|
||||
|
|
@ -49,6 +50,35 @@ describe('libraryItemsBookFilters placeholders', () => {
|
|||
libraryFolderId: libraryFolder.id,
|
||||
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 () => {
|
||||
|
|
@ -81,4 +111,28 @@ describe('libraryItemsBookFilters placeholders', () => {
|
|||
|
||||
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