From 75344dfc76cf6c29377d5c2261aa9ca4e688417a Mon Sep 17 00:00:00 2001 From: mattsigal Date: Sun, 19 Jul 2026 22:34:10 -0700 Subject: [PATCH 1/3] =?UTF-8?q?=EF=BB=BFfix(metadata):=20normalize=20accen?= =?UTF-8?q?ts=20and=20add=20fuzzy=20suffix=20matching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add diacritic/accent normalization (FormD character filtering) in LaunchBox and Libretro RDB name normalization to correctly resolve accented titles (like Pokémon). - Add fuzzy prefix-matching fallbacks in both matchers to handle title suffix discrepancies (such as regional PAL languages or subtitle suffixes). - Resolve actual No-Intro database names (Rdb.Name) in GamesService thumbnail lookups to correctly fetch regional cover arts from Libretro. - Fix C# Path extension-stripping bug by pre-stripping extensions using full paths instead of clean database names. --- Jellyfin/backend/Services/GameThumbService.cs | 4 +- Jellyfin/backend/Services/GamesService.cs | 8 +++- Jellyfin/backend/Services/LaunchBoxService.cs | 38 ++++++++++++++++++- Jellyfin/backend/Services/RdbService.cs | 38 ++++++++++++++++++- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/Jellyfin/backend/Services/GameThumbService.cs b/Jellyfin/backend/Services/GameThumbService.cs index aa18ba2..af13005 100644 --- a/Jellyfin/backend/Services/GameThumbService.cs +++ b/Jellyfin/backend/Services/GameThumbService.cs @@ -149,9 +149,9 @@ private static string BuildUrl(string platform, ThumbKind kind, string name) => /// minus the extension. This is the filename rather than the display title, which for a game /// in its own folder is the folder name and would not match. /// - private static string LibretroThumbName(string romFileName) + private static string LibretroThumbName(string name) { - var chars = (Path.GetFileNameWithoutExtension(romFileName) ?? string.Empty).ToCharArray(); + var chars = name.ToCharArray(); for (var i = 0; i < chars.Length; i++) { if (Array.IndexOf(ReservedChars, chars[i]) >= 0) diff --git a/Jellyfin/backend/Services/GamesService.cs b/Jellyfin/backend/Services/GamesService.cs index 35e1734..10d680d 100644 --- a/Jellyfin/backend/Services/GamesService.cs +++ b/Jellyfin/backend/Services/GamesService.cs @@ -515,7 +515,13 @@ public IReadOnlyList GetGames(string libraryId, string? systemId) var systemDir = FindSystemDir(library, romPath); var systemName = systemDir == null ? string.Empty : Path.GetFileName(systemDir); var core = ResolveSystemCore(systemName, new List { romPath }); - return (core, Path.GetFileName(romPath)); + + // Resolve the exact No-Intro name from Rdb if available so region codes/tags (like (USA)) are included in the art URL. + var title = ResolveTitle(systemDir, romPath); + var rdb = _rdb?.TryLookup(core, romPath, title); + var name = rdb?.Name ?? Path.GetFileNameWithoutExtension(romPath); + + return (core, name); } /// diff --git a/Jellyfin/backend/Services/LaunchBoxService.cs b/Jellyfin/backend/Services/LaunchBoxService.cs index 7d58042..daacbea 100644 --- a/Jellyfin/backend/Services/LaunchBoxService.cs +++ b/Jellyfin/backend/Services/LaunchBoxService.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; +using System.Globalization; using System.IO.Compression; +using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; @@ -103,6 +105,33 @@ public LaunchBoxService(IHttpClientFactory httpClientFactory, ILogger= 5) + { + string? bestKey = null; + LaunchBoxRecord? bestRecord = null; + var minDiff = int.MaxValue; + + foreach (var kv in index) + { + if (kv.Key.StartsWith(candidate, StringComparison.Ordinal) || + candidate.StartsWith(kv.Key, StringComparison.Ordinal)) + { + var diff = Math.Abs(kv.Key.Length - candidate.Length); + if (diff < minDiff) + { + minDiff = diff; + bestKey = kv.Key; + bestRecord = kv.Value; + } + } + } + + if (bestRecord != null && minDiff <= 20) + { + return bestRecord; + } + } } return null; @@ -320,10 +349,17 @@ private static LaunchBoxRecord ToRecord(XElement game, int ratings) /// filename and a clean LaunchBox title collapse to the same key. private static string NormalizeName(string value) { + var normalizedString = value.Normalize(NormalizationForm.FormD); var sb = new StringBuilder(value.Length); var depth = 0; - foreach (var ch in value) + foreach (var ch in normalizedString) { + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(ch); + if (unicodeCategory == UnicodeCategory.NonSpacingMark) + { + continue; + } + if (ch is '(' or '[') { depth++; diff --git a/Jellyfin/backend/Services/RdbService.cs b/Jellyfin/backend/Services/RdbService.cs index 31edf1c..976354e 100644 --- a/Jellyfin/backend/Services/RdbService.cs +++ b/Jellyfin/backend/Services/RdbService.cs @@ -1,5 +1,8 @@ using System.Collections.Concurrent; +using System.Globalization; +using System.Linq; using System.Net.Http; +using System.Text; using Microsoft.Extensions.Logging; namespace Moonfin.Server.Services; @@ -152,6 +155,32 @@ public RdbService(IHttpClientFactory httpClientFactory, ILogger logg } } + // Partial match fallback for name lookups + if (fileName.Length >= 5) + { + string? bestKey = null; + RdbRecord? bestRecord = null; + var minDiff = int.MaxValue; + foreach (var kv in index.ByName) + { + if (kv.Key.StartsWith(fileName, StringComparison.Ordinal) || + fileName.StartsWith(kv.Key, StringComparison.Ordinal)) + { + var diff = Math.Abs(kv.Key.Length - fileName.Length); + if (diff < minDiff) + { + minDiff = diff; + bestKey = kv.Key; + bestRecord = kv.Value; + } + } + } + if (bestRecord != null && minDiff <= 45) + { + return bestRecord; + } + } + return null; } @@ -350,9 +379,16 @@ private static uint[] BuildCrcTable() private static string NormalizeName(string value) { + var normalizedString = value.Normalize(NormalizationForm.FormD); var sb = new System.Text.StringBuilder(value.Length); - foreach (var ch in value) + foreach (var ch in normalizedString) { + var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(ch); + if (unicodeCategory == UnicodeCategory.NonSpacingMark) + { + continue; + } + if (char.IsLetterOrDigit(ch)) { sb.Append(char.ToLowerInvariant(ch)); From 7355f47503030e9a9a34e9c16eda1b1bb0d72681 Mon Sep 17 00:00:00 2001 From: RadicalMuffinMan <103554043+RadicalMuffinMan@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:22:25 -0400 Subject: [PATCH 2/3] Cleaned up the game metadata name-match fallback --- Jellyfin/backend/Services/GamesService.cs | 4 ++-- Jellyfin/backend/Services/LaunchBoxService.cs | 12 ++++++------ Jellyfin/backend/Services/RdbService.cs | 9 ++++----- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Jellyfin/backend/Services/GamesService.cs b/Jellyfin/backend/Services/GamesService.cs index 10d680d..520a3b2 100644 --- a/Jellyfin/backend/Services/GamesService.cs +++ b/Jellyfin/backend/Services/GamesService.cs @@ -515,8 +515,8 @@ public IReadOnlyList GetGames(string libraryId, string? systemId) var systemDir = FindSystemDir(library, romPath); var systemName = systemDir == null ? string.Empty : Path.GetFileName(systemDir); var core = ResolveSystemCore(systemName, new List { romPath }); - - // Resolve the exact No-Intro name from Rdb if available so region codes/tags (like (USA)) are included in the art URL. + + // Resolve the exact No-Intro name from Rdb if available so region codes and tags (like (USA)) are included in the art URL. var title = ResolveTitle(systemDir, romPath); var rdb = _rdb?.TryLookup(core, romPath, title); var name = rdb?.Name ?? Path.GetFileNameWithoutExtension(romPath); diff --git a/Jellyfin/backend/Services/LaunchBoxService.cs b/Jellyfin/backend/Services/LaunchBoxService.cs index daacbea..15cda89 100644 --- a/Jellyfin/backend/Services/LaunchBoxService.cs +++ b/Jellyfin/backend/Services/LaunchBoxService.cs @@ -1,7 +1,6 @@ using System.Collections.Concurrent; using System.Globalization; using System.IO.Compression; -using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; @@ -106,27 +105,28 @@ public LaunchBoxService(IHttpClientFactory httpClientFactory, ILogger= 5) { - string? bestKey = null; LaunchBoxRecord? bestRecord = null; var minDiff = int.MaxValue; - foreach (var kv in index) { - if (kv.Key.StartsWith(candidate, StringComparison.Ordinal) || + if (kv.Key.StartsWith(candidate, StringComparison.Ordinal) || candidate.StartsWith(kv.Key, StringComparison.Ordinal)) { var diff = Math.Abs(kv.Key.Length - candidate.Length); if (diff < minDiff) { minDiff = diff; - bestKey = kv.Key; bestRecord = kv.Value; } } } - + if (bestRecord != null && minDiff <= 20) { return bestRecord; diff --git a/Jellyfin/backend/Services/RdbService.cs b/Jellyfin/backend/Services/RdbService.cs index 976354e..33ae8a8 100644 --- a/Jellyfin/backend/Services/RdbService.cs +++ b/Jellyfin/backend/Services/RdbService.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; using System.Globalization; -using System.Linq; using System.Net.Http; using System.Text; using Microsoft.Extensions.Logging; @@ -155,22 +154,22 @@ public RdbService(IHttpClientFactory httpClientFactory, ILogger logg } } - // Partial match fallback for name lookups + // Fallback when the exact match misses: take the closest name where one + // side is a prefix of the other, to absorb region or edition suffixes. + // Rdb keeps region parentheticals, so it allows a wider gap than LaunchBox. if (fileName.Length >= 5) { - string? bestKey = null; RdbRecord? bestRecord = null; var minDiff = int.MaxValue; foreach (var kv in index.ByName) { - if (kv.Key.StartsWith(fileName, StringComparison.Ordinal) || + if (kv.Key.StartsWith(fileName, StringComparison.Ordinal) || fileName.StartsWith(kv.Key, StringComparison.Ordinal)) { var diff = Math.Abs(kv.Key.Length - fileName.Length); if (diff < minDiff) { minDiff = diff; - bestKey = kv.Key; bestRecord = kv.Value; } } From bac176b110484ab8815c65ec82c7f5b40d703c7e Mon Sep 17 00:00:00 2001 From: Axl Nunez <103554043+RadicalMuffinMan@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:57:39 -0400 Subject: [PATCH 3/3] Harden game metadata fuzzy matching --- Jellyfin/backend/Services/LaunchBoxService.cs | Bin 13195 -> 14423 bytes Jellyfin/backend/Services/RdbService.cs | 10 +++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/Jellyfin/backend/Services/LaunchBoxService.cs b/Jellyfin/backend/Services/LaunchBoxService.cs index 15cda89cec7a4f73a980b734a5adaf4110c43322..6ee12aad327315ee069bcb01f9ef1c2a353700a1 100644 GIT binary patch delta 1049 zcmZWoO-~b16x9Tw7Sqy}LLfHYVqzODg$N{mq!2|EBYq(^iG*{W?p^srcgJq zHExXe58%d~QNz*|F5TfmxB3HI_yY{yH>HHgZ05b0d(S!V-1A=5el$MXy&Gd=a2Gx4 zyubilDeGY)fIxy?MQA9roprE<>#hi^UI@SrU3b*+tGEts^s3{7U^UXh zASfAib!<6)%>Wynj^7A1tJb0wfF+eeOP6UkK0(l%OwLThv-ZJeg|&*?mwS_4*P4Qf z%=Qkppu(++@yfG&6rqa@fh8C@Hc|rZXcu8-rc*KJHFV4TqmAtFlw(8jFm`!KwQiw) zEZh)_9rzN+P{RRXm9`Rj8qat4v;|b@Pa2TD>mU7m*KFojFPp+0FB#Pu)4TtuNM|NeSq8 zwoLQMS^5kKdYhc44~evqPkYHM?In_Qq3w38|gnv)A} z#i@ld3|J(`DZs07`jxsuXVL@opudYM=|!US0`G_Ec;$ZWx!$KObU*k=SiO+J!B+Ja`*lX z;;T~7Omr|H(2~Q8Wq4F~8i(&nr#Wq!9jgv&N^;PJ;y_2Zxf97lUTPKMe-^|dEB=lQ rvWdg-{DEU+_n(dYHnV%*pX0JVdR@qnH_<~|**Mil2e!XvcXIy#F~?wo delta 237 zcmca!(4D>^jeGNMo=41+XXx-w4i`3?e8*gD@@!$d$^24^lRpWIOtw=JpDZd8I5|lq zXz~V;(8-LVij#BAIVP)#Dg)I>O^y~dnOq>oGPyv3W%6WE4KN#I)_YOe$p^&bCM$`B zfyFAse87B|+DBsEVDWlww#mlgYEU_j$>riYVDZJ`#*=-d_$Kp9@PO(0l3bI|s|$kV x&PYm4{vs(4)^mWLeX@a+3|Jhb-cE^avLBE>ug*KUM@V`yx6r1|)1(f{0s!3bO``w+ diff --git a/Jellyfin/backend/Services/RdbService.cs b/Jellyfin/backend/Services/RdbService.cs index 33ae8a8..6a1f16a 100644 --- a/Jellyfin/backend/Services/RdbService.cs +++ b/Jellyfin/backend/Services/RdbService.cs @@ -161,6 +161,7 @@ public RdbService(IHttpClientFactory httpClientFactory, ILogger logg { RdbRecord? bestRecord = null; var minDiff = int.MaxValue; + var ambiguous = false; foreach (var kv in index.ByName) { if (kv.Key.StartsWith(fileName, StringComparison.Ordinal) || @@ -171,10 +172,17 @@ public RdbService(IHttpClientFactory httpClientFactory, ILogger logg { minDiff = diff; bestRecord = kv.Value; + ambiguous = false; + } + else if (diff == minDiff && !ReferenceEquals(kv.Value, bestRecord)) + { + // Two different games are equally close, so a common prefix + // can't be resolved to one. Skip rather than pick wrong art. + ambiguous = true; } } } - if (bestRecord != null && minDiff <= 45) + if (bestRecord != null && !ambiguous && minDiff <= 45) { return bestRecord; }