Major matching improvements

Minor performance optimizations
This commit is contained in:
grimsi
2025-05-11 13:29:36 +02:00
parent 02f1a766be
commit ee9af0533f
2 changed files with 77 additions and 20 deletions
@@ -61,3 +61,48 @@ fun <K, V> Map<K, V?>.filterValuesNotNull() = filterValues { it != null } as Map
* and converting it to lowercase * and converting it to lowercase
*/ */
fun String.alphaNumeric() = filter { it.isLetterOrDigit() || it.isWhitespace() }.lowercase() fun String.alphaNumeric() = filter { it.isLetterOrDigit() || it.isWhitespace() }.lowercase()
/**
* Replaces standalone Roman numerals in a string with their corresponding integer values.
*
* Roman numerals are detected only when they appear as separate "words"—
* i.e., they are preceded by whitespace or the start of the string,
* and followed by a word boundary, whitespace, or the end of the string.
*
* Valid Roman numerals are assumed to be in the range 1 to 3999 (inclusive),
* in line with standard Roman numeral notation. Any match outside this range
* is ignored and left unchanged to avoid false positives.
*
* Example:
* "Helldivers II" -> "Helldivers 2"
* "Age of Empires III" -> "Age of Empires 3"
* "IVy League" -> "IVy League" (unchanged)
*/
fun String.replaceRomanNumerals(): String {
val romanNumeralMap = mapOf(
'M' to 1000, 'D' to 500, 'C' to 100,
'L' to 50, 'X' to 10, 'V' to 5, 'I' to 1
)
fun romanToInt(roman: String): Int {
var sum = 0
var prev = 0
for (char in roman.reversed()) {
val value = romanNumeralMap[char] ?: return -1
if (value < prev) sum -= value else sum += value
prev = value
}
return sum
}
val regex = Regex(
"""(?<=\s|^)(M{0,4}(CM|CD|D?C{0,3})?(XC|XL|L?X{0,3})?(IX|IV|V?I{0,3})?)(?=\b|\s|$)""",
RegexOption.IGNORE_CASE
)
return regex.replace(this) { match ->
val roman = match.value.uppercase()
val number = romanToInt(roman)
if (number in 1..3999) number.toString() else match.value
}
}
@@ -4,12 +4,12 @@ import de.grimsi.gameyfin.core.alphaNumeric
import de.grimsi.gameyfin.core.filterValuesNotNull import de.grimsi.gameyfin.core.filterValuesNotNull
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import de.grimsi.gameyfin.core.plugins.management.PluginManagementService import de.grimsi.gameyfin.core.plugins.management.PluginManagementService
import de.grimsi.gameyfin.core.replaceRomanNumerals
import de.grimsi.gameyfin.games.dto.GameDto import de.grimsi.gameyfin.games.dto.GameDto
import de.grimsi.gameyfin.games.dto.GameMetadataDto import de.grimsi.gameyfin.games.dto.GameMetadataDto
import de.grimsi.gameyfin.games.entities.* import de.grimsi.gameyfin.games.entities.*
import de.grimsi.gameyfin.games.repositories.GameRepository import de.grimsi.gameyfin.games.repositories.GameRepository
import de.grimsi.gameyfin.libraries.Library import de.grimsi.gameyfin.libraries.Library
import de.grimsi.gameyfin.media.ImageService
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
@@ -30,10 +30,11 @@ class GameService(
private val pluginManager: PluginManager, private val pluginManager: PluginManager,
private val pluginManagementService: PluginManagementService, private val pluginManagementService: PluginManagementService,
private val gameRepository: GameRepository, private val gameRepository: GameRepository,
private val companyService: CompanyService, private val companyService: CompanyService
private val imageService: ImageService
) { ) {
companion object { companion object {
const val TITLE_MATCH_MIN_RATIO = 90
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
} }
@@ -58,7 +59,6 @@ class GameService(
return gameRepository.saveAll(gamesToBePersisted) return gameRepository.saveAll(gamesToBePersisted)
} }
@Transactional
fun matchFromFile(path: Path, library: Library): Game? { fun matchFromFile(path: Path, library: Library): Game? {
val query = FilenameUtils.removeExtension(path.fileName.toString()) val query = FilenameUtils.removeExtension(path.fileName.toString())
@@ -75,15 +75,10 @@ class GameService(
// Step 2: Filter results to find the best matching title // Step 2: Filter results to find the best matching title
val filteredResults = filterResults(query, validResults) val filteredResults = filterResults(query, validResults)
// Step 3: Sort results by plugin priority // Step 3: Merge results into a single Game entity
val sortedResults = filteredResults.entries.sortedByDescending { val mergedGame = mergeResults(filteredResults, path, library)
pluginManagementService.getPluginManagementEntry(it.key.javaClass).priority
}
// Step 4: Merge results into a single Game entity // Step 4: Save the new game
val mergedGame = mergeResults(sortedResults, path, library)
// Step 5: Save the new game
return mergedGame return mergedGame
} }
@@ -148,12 +143,19 @@ class GameService(
originalQuery: String, originalQuery: String,
results: Map<GameMetadataProvider, GameMetadata> results: Map<GameMetadataProvider, GameMetadata>
): Map<GameMetadataProvider, GameMetadata> { ): Map<GameMetadataProvider, GameMetadata> {
val availableTitles = results.map { it.value.title } val providerToTitle = results.entries.associate {
val bestMatchingTitle = FuzzySearch.extractOne(originalQuery, availableTitles).string pluginManager.whichPlugin(it.key.javaClass).pluginId to it.value.title
}
log.debug { "Best matching title: '$bestMatchingTitle' for '$originalQuery' determined from $availableTitles" } val bestMatchingTitle = FuzzySearch.extractOne(originalQuery, providerToTitle.values).string
return results.filter { it.value.title.alphaNumeric() == bestMatchingTitle.alphaNumeric() } log.info {
"Best matching title: '$bestMatchingTitle' (${
providerToTitle.count { it.value.fuzzyMatchTitle(bestMatchingTitle) }
}/${providerToTitle.size} matches) for '$originalQuery' determined from $providerToTitle"
}
return results.filter { it.value.title.fuzzyMatchTitle(bestMatchingTitle) }
} }
/** /**
@@ -162,7 +164,7 @@ class GameService(
* The plugin with the highest possible priority is used as the source for each field * The plugin with the highest possible priority is used as the source for each field
*/ */
private fun mergeResults( private fun mergeResults(
results: List<Map.Entry<GameMetadataProvider, GameMetadata?>>, results: Map<GameMetadataProvider, GameMetadata?>,
path: Path, path: Path,
library: Library library: Library
): Game { ): Game {
@@ -170,13 +172,17 @@ class GameService(
val metadataMap = mutableMapOf<String, FieldMetadata>() val metadataMap = mutableMapOf<String, FieldMetadata>()
val originalIdsMap = mutableMapOf<PluginManagementEntry, String>() val originalIdsMap = mutableMapOf<PluginManagementEntry, String>()
// Cache the plugin management entries for each provider
val providerToManagementEntry =
results.entries.associate { it.key to pluginManagementService.getPluginManagementEntry(it.key.javaClass) }
// Sort results by plugin priority // Sort results by plugin priority
val sortedResults = results.sortedByDescending { val sortedResults = results.entries.sortedByDescending {
pluginManagementService.getPluginManagementEntry(it.key.javaClass).priority pluginManagementService.getPluginManagementEntry(it.key.javaClass).priority
} }
sortedResults.forEach { (provider, metadata) -> sortedResults.forEach { (provider, metadata) ->
val sourcePlugin = pluginManagementService.getPluginManagementEntry(provider.javaClass) val sourcePlugin = providerToManagementEntry[provider] ?: return@forEach
metadata?.let { metadata -> metadata?.let { metadata ->
originalIdsMap[sourcePlugin] = metadata.originalId originalIdsMap[sourcePlugin] = metadata.originalId
@@ -327,4 +333,10 @@ class GameService(
lastUpdated = metadata.lastUpdated lastUpdated = metadata.lastUpdated
) )
} }
private fun String.fuzzyMatchTitle(other: String, minRatio: Int = TITLE_MATCH_MIN_RATIO): Boolean {
return FuzzySearch.ratio(this.normalizeGameTitle(), other.normalizeGameTitle()) > minRatio
}
fun String.normalizeGameTitle(): String = this.alphaNumeric().replaceRomanNumerals()
} }