mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Major matching improvements
Minor performance optimizations
This commit is contained in:
@@ -60,4 +60,49 @@ fun <K, V> Map<K, V?>.filterValuesNotNull() = filterValues { it != null } as Map
|
||||
* Converts a string to an alphanumeric string by removing all non-alphanumeric characters (except whitespaces)
|
||||
* 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.plugins.management.PluginManagementEntry
|
||||
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.GameMetadataDto
|
||||
import de.grimsi.gameyfin.games.entities.*
|
||||
import de.grimsi.gameyfin.games.repositories.GameRepository
|
||||
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.GameMetadataProvider
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
@@ -30,10 +30,11 @@ class GameService(
|
||||
private val pluginManager: PluginManager,
|
||||
private val pluginManagementService: PluginManagementService,
|
||||
private val gameRepository: GameRepository,
|
||||
private val companyService: CompanyService,
|
||||
private val imageService: ImageService
|
||||
private val companyService: CompanyService
|
||||
) {
|
||||
companion object {
|
||||
const val TITLE_MATCH_MIN_RATIO = 90
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
}
|
||||
|
||||
@@ -58,7 +59,6 @@ class GameService(
|
||||
return gameRepository.saveAll(gamesToBePersisted)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun matchFromFile(path: Path, library: Library): Game? {
|
||||
val query = FilenameUtils.removeExtension(path.fileName.toString())
|
||||
|
||||
@@ -75,15 +75,10 @@ class GameService(
|
||||
// Step 2: Filter results to find the best matching title
|
||||
val filteredResults = filterResults(query, validResults)
|
||||
|
||||
// Step 3: Sort results by plugin priority
|
||||
val sortedResults = filteredResults.entries.sortedByDescending {
|
||||
pluginManagementService.getPluginManagementEntry(it.key.javaClass).priority
|
||||
}
|
||||
// Step 3: Merge results into a single Game entity
|
||||
val mergedGame = mergeResults(filteredResults, path, library)
|
||||
|
||||
// Step 4: Merge results into a single Game entity
|
||||
val mergedGame = mergeResults(sortedResults, path, library)
|
||||
|
||||
// Step 5: Save the new game
|
||||
// Step 4: Save the new game
|
||||
return mergedGame
|
||||
}
|
||||
|
||||
@@ -148,12 +143,19 @@ class GameService(
|
||||
originalQuery: String,
|
||||
results: Map<GameMetadataProvider, GameMetadata>
|
||||
): Map<GameMetadataProvider, GameMetadata> {
|
||||
val availableTitles = results.map { it.value.title }
|
||||
val bestMatchingTitle = FuzzySearch.extractOne(originalQuery, availableTitles).string
|
||||
val providerToTitle = results.entries.associate {
|
||||
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
|
||||
*/
|
||||
private fun mergeResults(
|
||||
results: List<Map.Entry<GameMetadataProvider, GameMetadata?>>,
|
||||
results: Map<GameMetadataProvider, GameMetadata?>,
|
||||
path: Path,
|
||||
library: Library
|
||||
): Game {
|
||||
@@ -170,13 +172,17 @@ class GameService(
|
||||
val metadataMap = mutableMapOf<String, FieldMetadata>()
|
||||
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
|
||||
val sortedResults = results.sortedByDescending {
|
||||
val sortedResults = results.entries.sortedByDescending {
|
||||
pluginManagementService.getPluginManagementEntry(it.key.javaClass).priority
|
||||
}
|
||||
|
||||
sortedResults.forEach { (provider, metadata) ->
|
||||
val sourcePlugin = pluginManagementService.getPluginManagementEntry(provider.javaClass)
|
||||
val sourcePlugin = providerToManagementEntry[provider] ?: return@forEach
|
||||
|
||||
metadata?.let { metadata ->
|
||||
originalIdsMap[sourcePlugin] = metadata.originalId
|
||||
@@ -327,4 +333,10 @@ class GameService(
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user