diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/Utils.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/Utils.kt index 70fa700..342b0a0 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/Utils.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/Utils.kt @@ -60,4 +60,49 @@ fun Map.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() \ No newline at end of file +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 + } +} diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt index 6ae511a..07b879d 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt @@ -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 ): Map { - 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>, + results: Map, path: Path, library: Library ): Game { @@ -170,13 +172,17 @@ class GameService( val metadataMap = mutableMapOf() val originalIdsMap = mutableMapOf() + // 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() } \ No newline at end of file