Add a minimum confidence level threshold to match after scan runs.

This commit is contained in:
Marke Hallowell 2025-11-07 23:28:21 -08:00
parent 0ab72d879e
commit 3a751f711a
7 changed files with 54 additions and 7 deletions

View File

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

View File

@ -9,6 +9,10 @@
<div class="mt-4"> <div class="mt-4">
<ui-checkbox v-model="matchAfterScan" @input="updateMatchAfterScan" :label="$strings.LabelMatchAfterScan" medium checkbox-bg="bg" label-class="pl-2 text-base" /> <ui-checkbox v-model="matchAfterScan" @input="updateMatchAfterScan" :label="$strings.LabelMatchAfterScan" medium checkbox-bg="bg" label-class="pl-2 text-base" />
</div> </div>
<div class="mt-4" v-if="matchAfterScan">
<label class="px-1 text-sm font-semibold">{{ $strings.LabelMatchMinConfidence }}</label>
<ui-range-input v-model.number="matchMinConfidencePercentage" :min="0" :max="100" :step="1" />
</div>
</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>
@ -29,10 +33,21 @@ export default {
return { return {
cronExpression: null, cronExpression: null,
enableAutoScan: false, enableAutoScan: false,
matchAfterScan: false matchAfterScan: false,
matchMinConfidence: 0
}
},
computed: {
matchMinConfidencePercentage: {
get() {
return this.matchMinConfidence * 100
},
set(val) {
this.matchMinConfidence = val / 100
this.updateMatchMinConfidence()
}
} }
}, },
computed: {},
methods: { methods: {
checkBlurExpressionInput() { checkBlurExpressionInput() {
// returns true if advanced cron input is focused // returns true if advanced cron input is focused
@ -60,10 +75,18 @@ export default {
} }
}) })
}, },
updateMatchMinConfidence() {
this.$emit('update', {
settings: {
matchMinConfidence: this.matchMinConfidence
}
})
},
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 this.matchAfterScan = this.library.settings.matchAfterScan
this.matchMinConfidence = this.library.settings.matchMinConfidence || 0
} }
}, },
mounted() { mounted() {

View File

@ -449,6 +449,7 @@
"LabelLowestPriority": "Lowest Priority", "LabelLowestPriority": "Lowest Priority",
"LabelMatchConfidence": "Confidence", "LabelMatchConfidence": "Confidence",
"LabelMatchAfterScan": "Run 'Match Books' after scan", "LabelMatchAfterScan": "Run 'Match Books' after scan",
"LabelMatchMinConfidence": "Minimum match confidence (0-100)",
"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,19 @@ 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 === 'matchMinConfidence') {
if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {
Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be a number`)
return res.status(400).send(`Invalid request. Setting "${key}" must be a number`)
} else if (req.body.settings[key] !== null && (Number(req.body.settings[key]) < 0 || Number(req.body.settings[key]) > 1)) {
Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be between 0 and 1`)
return res.status(400).send(`Invalid request. Setting "${key}" must be between 0 and 1`)
}
if (req.body.settings[key] !== updatedSettings[key]) {
hasUpdates = true
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]}"`)
}
} else if (key === 'matchAfterScan') { } else if (key === 'matchAfterScan') {
if (typeof req.body.settings[key] !== 'boolean') { if (typeof req.body.settings[key] !== 'boolean') {
return res.status(400).send('Invalid request. Setting "matchAfterScan" must be a boolean') return res.status(400).send('Invalid request. Setting "matchAfterScan" must be a boolean')

View File

@ -84,7 +84,9 @@ class CronManager {
checkRemoveEmptySeries, checkRemoveEmptySeries,
checkRemoveAuthorsWithNoBooks checkRemoveAuthorsWithNoBooks
} }
Scanner.matchLibraryItems(apiRouterCtx, library) Scanner.matchLibraryItems(apiRouterCtx, library, {
minConfidence: library.settings.matchMinConfidence
})
} }
} }
}) })

View File

@ -69,6 +69,7 @@ class Library extends Model {
disableWatcher: false, disableWatcher: false,
autoScanCronExpression: null, autoScanCronExpression: null,
matchAfterScan: false, matchAfterScan: false,
matchMinConfidence: 0,
skipMatchingMediaWithAsin: false, skipMatchingMediaWithAsin: false,
skipMatchingMediaWithIsbn: false, skipMatchingMediaWithIsbn: false,
audiobooksOnly: false, audiobooksOnly: false,

View File

@ -60,6 +60,12 @@ class Scanner {
} }
const matchData = results[0] const matchData = results[0]
if (options.minConfidence && matchData.matchConfidence < options.minConfidence) {
return {
warning: `Match confidence ${matchData.matchConfidence} is below the minimum of ${options.minConfidence}`
}
}
// Update cover if not set OR overrideCover flag // Update cover if not set OR overrideCover flag
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) { if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`) Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
@ -433,7 +439,7 @@ class Scanner {
* @param {LibraryScan} libraryScan * @param {LibraryScan} libraryScan
* @returns {Promise<boolean>} false if scan canceled * @returns {Promise<boolean>} false if scan canceled
*/ */
async matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan) { async matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan, options = {}) {
for (let i = 0; i < libraryItems.length; i++) { for (let i = 0; i < libraryItems.length; i++) {
const libraryItem = libraryItems[i] const libraryItem = libraryItems[i]
@ -448,7 +454,7 @@ class Scanner {
} }
Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.title}" (${i + 1} of ${libraryItems.length})`) Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.title}" (${i + 1} of ${libraryItems.length})`)
const result = await this.quickMatchLibraryItem(apiRouterCtx, libraryItem, { provider: library.provider }) const result = await this.quickMatchLibraryItem(apiRouterCtx, libraryItem, { provider: library.provider, minConfidence: options.minConfidence })
if (result.warning) { if (result.warning) {
Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.title}"`) Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.title}"`)
} else if (result.updated) { } else if (result.updated) {
@ -470,7 +476,7 @@ class Scanner {
* @param {import('../routers/ApiRouter')} apiRouterCtx * @param {import('../routers/ApiRouter')} apiRouterCtx
* @param {import('../models/Library')} library * @param {import('../models/Library')} library
*/ */
async matchLibraryItems(apiRouterCtx, library) { async matchLibraryItems(apiRouterCtx, library, options = {}) {
if (library.mediaType === 'podcast') { if (library.mediaType === 'podcast') {
Logger.error(`[Scanner] matchLibraryItems: Match all not supported for podcasts yet`) Logger.error(`[Scanner] matchLibraryItems: Match all not supported for podcasts yet`)
return return
@ -509,7 +515,7 @@ class Scanner {
offset += limit offset += limit
hasMoreChunks = libraryItems.length === limit hasMoreChunks = libraryItems.length === limit
const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan) const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan, options)
if (!shouldContinue) { if (!shouldContinue) {
isCanceled = true isCanceled = true
break break