Adds a 'Match after scan' option to the manual schedule configuration options for scanning.

This commit is contained in:
Marke Hallowell 2025-11-03 20:55:44 -08:00
parent 0c7b738b7c
commit 0ab72d879e
8 changed files with 169 additions and 101 deletions

View File

@ -125,6 +125,7 @@ export default {
skipMatchingMediaWithAsin: false, skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false, skipMatchingMediaWithIsbn: false,
autoScanCronExpression: null, autoScanCronExpression: null,
matchAfterScan: false,
hideSingleBookSeries: false, hideSingleBookSeries: false,
onlyShowLaterBooksInContinueSeries: false, onlyShowLaterBooksInContinueSeries: false,
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'], metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'],

View File

@ -4,7 +4,12 @@
<p class="text-base md:text-xl font-semibold">{{ $strings.HeaderScheduleLibraryScans }}</p> <p class="text-base md:text-xl font-semibold">{{ $strings.HeaderScheduleLibraryScans }}</p>
<ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" /> <ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" :label="$strings.LabelEnable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
</div> </div>
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoScan" v-model="cronExpression" @input="updatedCron" /> <div v-if="enableAutoScan">
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-model="cronExpression" @input="updatedCron" />
<div class="mt-4">
<ui-checkbox v-model="matchAfterScan" @input="updateMatchAfterScan" :label="$strings.LabelMatchAfterScan" medium checkbox-bg="bg" label-class="pl-2 text-base" />
</div>
</div>
<div v-else> <div v-else>
<p class="text-yellow-400 text-base">{{ $strings.MessageScheduleLibraryScanNote }}</p> <p class="text-yellow-400 text-base">{{ $strings.MessageScheduleLibraryScanNote }}</p>
</div> </div>
@ -23,7 +28,8 @@ export default {
data() { data() {
return { return {
cronExpression: null, cronExpression: null,
enableAutoScan: false enableAutoScan: false,
matchAfterScan: false
} }
}, },
computed: {}, computed: {},
@ -47,9 +53,17 @@ export default {
} }
}) })
}, },
updateMatchAfterScan(value) {
this.$emit('update', {
settings: {
matchAfterScan: value
}
})
},
init() { init() {
this.cronExpression = this.library.settings.autoScanCronExpression this.cronExpression = this.library.settings.autoScanCronExpression
this.enableAutoScan = !!this.cronExpression this.enableAutoScan = !!this.cronExpression
this.matchAfterScan = this.library.settings.matchAfterScan
} }
}, },
mounted() { mounted() {

View File

@ -448,6 +448,7 @@
"LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date",
"LabelLowestPriority": "Lowest Priority", "LabelLowestPriority": "Lowest Priority",
"LabelMatchConfidence": "Confidence", "LabelMatchConfidence": "Confidence",
"LabelMatchAfterScan": "Run 'Match Books' after scan",
"LabelMatchExistingUsersBy": "Match existing users by", "LabelMatchExistingUsersBy": "Match existing users by",
"LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider",
"LabelMaxEpisodesToDownload": "Max # of episodes to download. Use 0 for unlimited.", "LabelMaxEpisodesToDownload": "Max # of episodes to download. Use 0 for unlimited.",

View File

@ -354,6 +354,15 @@ class LibraryController {
updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key]) updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])
Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`) Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
} }
} else if (key === 'matchAfterScan') {
if (typeof req.body.settings[key] !== 'boolean') {
return res.status(400).send('Invalid request. Setting "matchAfterScan" must be a boolean')
}
if (req.body.settings[key] !== updatedSettings[key]) {
hasUpdates = true
updatedSettings[key] = req.body.settings[key]
Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
}
} else { } else {
if (typeof req.body.settings[key] !== typeof updatedSettings[key]) { if (typeof req.body.settings[key] !== typeof updatedSettings[key]) {
Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be of type ${typeof updatedSettings[key]}`) Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be of type ${typeof updatedSettings[key]}`)

View File

@ -3,6 +3,8 @@ const cron = require('../libs/nodeCron')
const Logger = require('../Logger') const Logger = require('../Logger')
const Database = require('../Database') const Database = require('../Database')
const LibraryScanner = require('../scanner/LibraryScanner') const LibraryScanner = require('../scanner/LibraryScanner')
const Scanner = require('../scanner/Scanner')
const { checkRemoveEmptySeries, checkRemoveAuthorsWithNoBooks } = require('../utils/cleanup')
const ShareManager = require('./ShareManager') const ShareManager = require('./ShareManager')
@ -74,7 +76,16 @@ class CronManager {
Logger.error(`[CronManager] Library not found for scan cron ${_library.id}`) Logger.error(`[CronManager] Library not found for scan cron ${_library.id}`)
} else { } else {
Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`) Logger.debug(`[CronManager] Library scan cron executing for ${library.name}`)
LibraryScanner.scan(library) await LibraryScanner.scan(library)
if (library.settings.matchAfterScan) {
Logger.debug(`[CronManager] Library scan cron matching books for ${library.name}`)
const apiRouterCtx = {
checkRemoveEmptySeries,
checkRemoveAuthorsWithNoBooks
}
Scanner.matchLibraryItems(apiRouterCtx, library)
}
} }
}) })
this.libraryScanCrons.push({ this.libraryScanCrons.push({

View File

@ -68,6 +68,7 @@ class Library extends Model {
coverAspectRatio: 1, // Square coverAspectRatio: 1, // Square
disableWatcher: false, disableWatcher: false,
autoScanCronExpression: null, autoScanCronExpression: null,
matchAfterScan: false,
skipMatchingMediaWithAsin: false, skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false, skipMatchingMediaWithIsbn: false,
audiobooksOnly: false, audiobooksOnly: false,

View File

@ -35,6 +35,7 @@ const MiscController = require('../controllers/MiscController')
const ShareController = require('../controllers/ShareController') const ShareController = require('../controllers/ShareController')
const StatsController = require('../controllers/StatsController') const StatsController = require('../controllers/StatsController')
const ApiKeyController = require('../controllers/ApiKeyController') const ApiKeyController = require('../controllers/ApiKeyController')
const { checkRemoveEmptySeries, checkRemoveAuthorsWithNoBooks } = require('../utils/cleanup')
class ApiRouter { class ApiRouter {
constructor(Server) { constructor(Server) {
@ -405,54 +406,7 @@ class ApiRouter {
* @param {string[]} seriesIds * @param {string[]} seriesIds
*/ */
async checkRemoveEmptySeries(seriesIds) { async checkRemoveEmptySeries(seriesIds) {
if (!seriesIds?.length) return return checkRemoveEmptySeries(seriesIds)
const transaction = await Database.sequelize.transaction()
try {
const seriesToRemove = (
await Database.seriesModel.findAll({
where: [
{
id: seriesIds
},
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 0)
],
attributes: ['id', 'name', 'libraryId'],
include: {
model: Database.bookModel,
attributes: ['id'],
required: false // Ensure it includes series even if no books exist
},
transaction
})
).map((s) => ({ id: s.id, name: s.name, libraryId: s.libraryId }))
if (seriesToRemove.length) {
await Database.seriesModel.destroy({
where: {
id: seriesToRemove.map((s) => s.id)
},
transaction
})
}
await transaction.commit()
seriesToRemove.forEach(({ id, name, libraryId }) => {
Logger.info(`[ApiRouter] Series "${name}" is now empty. Removing series`)
// Remove series from library filter data
Database.removeSeriesFromFilterData(libraryId, id)
SocketAuthority.emitter('series_removed', { id: id, libraryId: libraryId })
})
// Close rss feeds - remove from db and emit socket event
if (seriesToRemove.length) {
await RssFeedManager.closeFeedsForEntityIds(seriesToRemove.map((s) => s.id))
}
} catch (error) {
await transaction.rollback()
Logger.error(`[ApiRouter] Error removing empty series: ${error.message}`)
}
} }
/** /**
@ -463,56 +417,7 @@ class ApiRouter {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async checkRemoveAuthorsWithNoBooks(authorIds) { async checkRemoveAuthorsWithNoBooks(authorIds) {
if (!authorIds?.length) return return checkRemoveAuthorsWithNoBooks(authorIds)
const transaction = await Database.sequelize.transaction()
try {
// Select authors with locking to prevent concurrent updates
const bookAuthorsToRemove = (
await Database.authorModel.findAll({
where: [
{
id: authorIds,
asin: {
[sequelize.Op.or]: [null, '']
},
description: {
[sequelize.Op.or]: [null, '']
},
imagePath: {
[sequelize.Op.or]: [null, '']
}
},
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)
],
attributes: ['id', 'name', 'libraryId'],
raw: true,
transaction
})
).map((au) => ({ id: au.id, name: au.name, libraryId: au.libraryId }))
if (bookAuthorsToRemove.length) {
await Database.authorModel.destroy({
where: {
id: bookAuthorsToRemove.map((au) => au.id)
},
transaction
})
}
await transaction.commit()
// Remove all book authors after completing remove from database
bookAuthorsToRemove.forEach(({ id, name, libraryId }) => {
Database.removeAuthorFromFilterData(libraryId, id)
// TODO: Clients were expecting full author in payload but its unnecessary
SocketAuthority.emitter('author_removed', { id, libraryId })
Logger.info(`[ApiRouter] Removed author "${name}" with no books`)
})
} catch (error) {
await transaction.rollback()
Logger.error(`[ApiRouter] Error removing authors: ${error.message}`)
}
} }
async getUserListeningSessionsHelper(userId) { async getUserListeningSessionsHelper(userId) {

126
server/utils/cleanup.js Normal file
View File

@ -0,0 +1,126 @@
const sequelize = require('sequelize')
const Logger = require('../Logger')
const Database = require('../Database')
const SocketAuthority = require('../SocketAuthority')
const RssFeedManager = require('../managers/RssFeedManager')
/**
* After deleting book(s), remove empty series
*
* @param {string[]} seriesIds
*/
async function checkRemoveEmptySeries(seriesIds) {
if (!seriesIds?.length) return
const transaction = await Database.sequelize.transaction()
try {
const seriesToRemove = (
await Database.seriesModel.findAll({
where: [
{
id: seriesIds
},
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 0)
],
attributes: ['id', 'name', 'libraryId'],
include: {
model: Database.bookModel,
attributes: ['id'],
required: false // Ensure it includes series even if no books exist
},
transaction
})
).map((s) => ({ id: s.id, name: s.name, libraryId: s.libraryId }))
if (seriesToRemove.length) {
await Database.seriesModel.destroy({
where: {
id: seriesToRemove.map((s) => s.id)
},
transaction
})
}
await transaction.commit()
seriesToRemove.forEach(({ id, name, libraryId }) => {
Logger.info(`[ApiRouter] Series "${name}" is now empty. Removing series`)
// Remove series from library filter data
Database.removeSeriesFromFilterData(libraryId, id)
SocketAuthority.emitter('series_removed', { id: id, libraryId: libraryId })
})
// Close rss feeds - remove from db and emit socket event
if (seriesToRemove.length) {
await RssFeedManager.closeFeedsForEntityIds(seriesToRemove.map((s) => s.id))
}
} catch (error) {
await transaction.rollback()
Logger.error(`[ApiRouter] Error removing empty series: ${error.message}`)
}
}
/**
* Remove authors with no books and unset asin, description and imagePath
* Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged)
*
* @param {string[]} authorIds
* @returns {Promise<void>}
*/
async function checkRemoveAuthorsWithNoBooks(authorIds) {
if (!authorIds?.length) return
const transaction = await Database.sequelize.transaction()
try {
// Select authors with locking to prevent concurrent updates
const bookAuthorsToRemove = (
await Database.authorModel.findAll({
where: [
{
id: authorIds,
asin: {
[sequelize.Op.or]: [null, '']
},
description: {
[sequelize.Op.or]: [null, '']
},
imagePath: {
[sequelize.Op.or]: [null, '']
}
},
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)
],
attributes: ['id', 'name', 'libraryId'],
raw: true,
transaction
})
).map((au) => ({ id: au.id, name: au.name, libraryId: au.libraryId }))
if (bookAuthorsToRemove.length) {
await Database.authorModel.destroy({
where: {
id: bookAuthorsToRemove.map((au) => au.id)
},
transaction
})
}
await transaction.commit()
// Remove all book authors after completing remove from database
bookAuthorsToRemove.forEach(({ id, name, libraryId }) => {
Database.removeAuthorFromFilterData(libraryId, id)
// TODO: Clients were expecting full author in payload but its unnecessary
SocketAuthority.emitter('author_removed', { id, libraryId })
Logger.info(`[ApiRouter] Removed author "${name}" with no books`)
})
} catch (error) {
await transaction.rollback()
Logger.error(`[ApiRouter] Error removing authors: ${error.message}`)
}
}
module.exports = {
checkRemoveEmptySeries,
checkRemoveAuthorsWithNoBooks
}