feat(igdb): rank game search by popularity and help with typos
Build, Test & Push Core Service / quality-check (push) Successful in 49s
Build, Test & Push Core Service / docker (push) Successful in 1m36s

IGDB lists every e-Reader card as a game, and its own order
puts obscure entries first. Core now drops e-Reader-only
games, asks for 50 hits and sorts exact name first, then by
rating count. When a search finds fewer than 3 games, Core
searches again without doubled letters and with single
words, and adds those hits by Levenshtein distance after
IGDB's own hits.

Also fixes a flaky test that read the time to beat before
the background refresh wrote it.
This commit is contained in:
lobo committed 2026-09-26 06:29:57 +02:00
1 parent e1d837bc78
commit 1cac7de836
8 files changed
+307 -7

No files matched your search

@@ -40,7 +40,8 @@ public final class IgdbResponses {
@JsonProperty("artworks") java.util.List<Image> artworks,
@JsonProperty("screenshots") java.util.List<Image> screenshots,
@JsonProperty("platforms") java.util.List<Platform> platforms,
@JsonProperty("external_games") java.util.List<ExternalGameSource> externalGames
@JsonProperty("external_games") java.util.List<ExternalGameSource> externalGames,
@JsonProperty("total_rating_count") Integer totalRatingCount
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
@@ -49,13 +49,17 @@ public class IgdbApiService {
*/
public record Game(long igdbId, String name, Integer year, String imageUrl, List<String> platforms, Long steamAppid) {}
// IGDB's own order puts obscure entries first, so Core asks for more and sorts by popularity itself.
private static final int SEARCH_FETCH_LIMIT = 50;
private static final int SEARCH_LIMIT = 20;
// IGDB lists every e-Reader card as a game of its own.
private static final String E_READER = "e-Reader / Card-e Reader";
// IGDB game types a user can own and play on their own: main game, standalone expansion, season, remake,
// remaster, expanded game, port. Leaves out DLC, bundles, mods, episodes, packs and updates.
private static final String PLAYABLE_GAME_TYPES = "(0,4,7,8,9,10,11)";
private static final String GAME_FIELDS = "fields name,first_release_date,cover.image_id,artworks.image_id,"
+ "screenshots.image_id,platforms.name,platforms.abbreviation,external_games.external_game_source,"
+ "external_games.uid;";
+ "external_games.uid,total_rating_count;";
private static final String IMAGE_BASE = "https://images.igdb.com/igdb/image/upload/";
// IGDB answers at most 500 rows per query, and one Steam app can have more than one external_games row.
@@ -120,9 +124,18 @@ public class IgdbApiService {
return Optional.empty();
}
try {
IgdbResponses.Game[] games = query("/games", "search \"" + searchText(query) + "\"; " + GAME_FIELDS
+ " where version_parent = null & game_type = " + PLAYABLE_GAME_TYPES + "; limit " + SEARCH_LIMIT + ";", IgdbResponses.Game[].class);
return Optional.of(Arrays.stream(games).map(IgdbApiService::toGame).flatMap(Optional::stream).toList());
String text = searchText(query);
IgdbResponses.Game[] games = query("/games", "search \"" + text + "\"; " + GAME_FIELDS
+ " where version_parent = null & game_type = " + PLAYABLE_GAME_TYPES + "; limit " + SEARCH_FETCH_LIMIT + ";",
IgdbResponses.Game[].class);
return Optional.of(Arrays.stream(games)
.filter(game -> !onlyOnTheEReader(game))
.sorted(Comparator.comparing((IgdbResponses.Game game) -> !text.equalsIgnoreCase(trimmedName(game)))
.thenComparing(game -> game.totalRatingCount() == null ? 0 : game.totalRatingCount(), Comparator.reverseOrder()))
.map(IgdbApiService::toGame)
.flatMap(Optional::stream)
.limit(SEARCH_LIMIT)
.toList());
} catch (Exception e) {
log.error("Error searching IGDB games", e);
return Optional.empty();
@@ -176,6 +189,15 @@ public class IgdbApiService {
}
}
private static boolean onlyOnTheEReader(IgdbResponses.Game game) {
return game.platforms() != null && !game.platforms().isEmpty()
&& game.platforms().stream().allMatch(platform -> E_READER.equalsIgnoreCase(platform.name()));
}
private static String trimmedName(IgdbResponses.Game game) {
return game.name() == null ? "" : game.name().trim();
}
private static Optional<Game> toGame(IgdbResponses.Game game) {
if (game.id() == null || game.name() == null || game.name().isBlank()) {
return Optional.empty();
@@ -0,0 +1,78 @@
package core_service.modules.library.service;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
/**
* Typo help for the IGDB game search, which only finds exact words. When a search finds too little, Core searches
* again with these variants and sorts their hits by how close the name is to what the user typed.
*/
final class FuzzySearch {
private static final int MIN_WORD_LENGTH = 4;
private static final int MAX_WORDS = 2;
private FuzzySearch() {
}
/**
* "Superr Mario" is "Super Mario"; empty when no letter is doubled.
*/
static Optional<String> withoutDoubledLetters(String query) {
String text = normalizeSpaces(query);
String without = text.replaceAll("(?i)(\\p{L})\\1+", "$1");
return without.equals(text) ? Optional.empty() : Optional.of(without);
}
/**
* The first two words of 4+ letters, to search each alone; none for a single word.
*/
static List<String> words(String query) {
String[] words = normalizeSpaces(query).split(" ");
if (words.length < 2) {
return List.of();
}
return Arrays.stream(words).filter(word -> word.length() >= MIN_WORD_LENGTH).limit(MAX_WORDS).toList();
}
/**
* Compares the query with as many words of the name as the query has: "superr mario" is 1 away from
* "Super Mario Bros. 3".
*/
static int distance(String query, String name) {
List<String> queryWords = lowerWords(query);
List<String> nameWords = lowerWords(name);
String start = String.join(" ", nameWords.subList(0, Math.min(queryWords.size(), nameWords.size())));
return levenshtein(String.join(" ", queryWords), start);
}
static int levenshtein(String a, String b) {
int[] previous = new int[b.length() + 1];
int[] current = new int[b.length() + 1];
for (int j = 0; j <= b.length(); j++) {
previous[j] = j;
}
for (int i = 1; i <= a.length(); i++) {
current[0] = i;
for (int j = 1; j <= b.length(); j++) {
int change = a.charAt(i - 1) == b.charAt(j - 1) ? 0 : 1;
current[j] = Math.min(Math.min(current[j - 1] + 1, previous[j] + 1), previous[j - 1] + change);
}
int[] swap = previous;
previous = current;
current = swap;
}
return previous[b.length()];
}
private static String normalizeSpaces(String text) {
return text.trim().replaceAll("\\s+", " ");
}
private static List<String> lowerWords(String text) {
String cleaned = text.toLowerCase(Locale.ROOT).replaceAll("[^\\p{L}\\p{N}]+", " ").trim();
return cleaned.isEmpty() ? List.of() : List.of(cleaned.split(" "));
}
}
@@ -18,11 +18,14 @@ import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.stream.Stream;
/**
* Games the user adds by hand from IGDB, e.g. owned on PlayStation or Switch. See GameSource for how they live next
@@ -44,6 +47,9 @@ public class ManualGameService {
public enum ChangeResult { DONE, NOT_FOUND, STEAM_GAME }
private static final int MIN_HITS = 3;
private static final int MAX_HITS = 20;
private final IgdbApiService igdbApiService;
private final UserGameRepository userGameRepository;
private final SteamGameRepository steamGameRepository;
@@ -67,7 +73,7 @@ public class ManualGameService {
if (!igdbApiService.isEnabled()) {
return Optional.of(new GameSearchDto(false, List.of()));
}
return igdbApiService.searchGames(query).map(games -> {
return igdbApiService.searchGames(query).map(found -> withTypoHelp(query, found)).map(games -> {
Set<Long> owned = new HashSet<>(userGameRepository.findActiveAppids(userId,
games.stream().flatMap(game -> possibleKeys(game).stream()).toList()));
return new GameSearchDto(true, games.stream()
@@ -77,6 +83,35 @@ public class ManualGameService {
});
}
// Few hits often mean a typo. IGDB's own hits keep their order; the variant hits follow, closest name first.
// The variants go through the same cache, and one that fails adds nothing.
private List<IgdbApiService.Game> withTypoHelp(String query, List<IgdbApiService.Game> found) {
if (found.size() >= MIN_HITS) {
return found;
}
List<String> variants = new ArrayList<>();
if (found.isEmpty()) {
FuzzySearch.withoutDoubledLetters(query).ifPresent(variants::add);
}
variants.addAll(FuzzySearch.words(query));
Set<Long> seen = new HashSet<>();
found.forEach(game -> seen.add(game.igdbId()));
List<IgdbApiService.Game> extra = new ArrayList<>();
for (String variant : variants) {
if (found.size() + extra.size() >= MIN_HITS) {
break;
}
igdbApiService.searchGames(variant).orElse(List.of()).stream()
.filter(game -> seen.add(game.igdbId()))
.forEach(extra::add);
}
return Stream.concat(found.stream(),
extra.stream().sorted(Comparator.comparingInt(game -> FuzzySearch.distance(query, game.name()))))
.limit(MAX_HITS)
.toList();
}
/**
* Adds the game under its Steam appid when IGDB knows it on Steam: then the sync takes it over once the user owns
* it there, and it keeps one row. A game the user removed before comes back with its old status and notes.
@@ -194,7 +194,8 @@ class IgdbApiServiceTest extends AbstractUnitTest {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(allOf(containsString("search \"zelda\";"), containsString("version_parent = null"),
containsString("game_type = (0,4,7,8,9,10,11)"), containsString("limit 20"))))
containsString("game_type = (0,4,7,8,9,10,11)"), containsString("total_rating_count"),
containsString("limit 50"))))
.andRespond(withSuccess(ZELDA, MediaType.APPLICATION_JSON));
List<IgdbApiService.Game> games = service.searchGames(" zelda ").orElseThrow();
@@ -206,6 +207,32 @@ class IgdbApiServiceTest extends AbstractUnitTest {
"https://images.igdb.com/igdb/image/upload/t_screenshot_med/ar1.jpg", List.of("PS5", "PC"), 1245620L));
}
@Test
void searchGames_PutsTheExactNameFirstThenTheGamesWithMoreRatings() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andRespond(withSuccess("[{\"id\": 1, \"name\": \"Super Super Super Super\"},"
+ " {\"id\": 2, \"name\": \"Super Mario Galaxy 2\", \"total_rating_count\": 900},"
+ " {\"id\": 3, \"name\": \"Super Mario Bros.\", \"total_rating_count\": 1500},"
+ " {\"id\": 4, \"name\": \"Super Mario\", \"total_rating_count\": 5}]", MediaType.APPLICATION_JSON));
assertThat(service.searchGames("super mario").orElseThrow()).extracting(IgdbApiService.Game::igdbId)
.containsExactly(4L, 3L, 2L, 1L);
}
@Test
void searchGames_LeavesOutGamesOnlyOnTheEReader() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andRespond(withSuccess("[{\"id\": 1, \"name\": \"Super Mario Advance 4: Super Mario Bros. 3-e - Para Beetle Challenge\","
+ " \"platforms\": [{\"id\": 510, \"name\": \"e-Reader / Card-e Reader\"}]},"
+ " {\"id\": 2, \"name\": \"Super Mario Advance 4\", \"platforms\": [{\"id\": 24, \"name\": \"Game Boy Advance\", \"abbreviation\": \"GBA\"},"
+ " {\"id\": 510, \"name\": \"e-Reader / Card-e Reader\"}]}]", MediaType.APPLICATION_JSON));
assertThat(service.searchGames("super mario").orElseThrow()).extracting(IgdbApiService.Game::igdbId)
.containsExactly(2L);
}
@Test
void searchGames_OnlyACover_UsesTheCover() {
expectToken();
@@ -305,6 +305,7 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
@Test
void gameAddedByHand_ShowsUpInTheLibraryAndItsDetail() throws Exception {
add(119388, "Switch");
timeToBeatStored(-119388L);
mockMvc.perform(get("/api/library/games").param("q", "zelda"))
.andExpect(status().isOk())
@@ -0,0 +1,43 @@
package core_service.modules.library.service;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class FuzzySearchTest extends AbstractUnitTest {
@Test
void withoutDoubledLetters_KeepsOneLetterOfEachRun() {
assertThat(FuzzySearch.withoutDoubledLetters("Superr Marioo")).contains("Super Mario");
}
@Test
void withoutDoubledLetters_NothingDoubled_IsEmpty() {
assertThat(FuzzySearch.withoutDoubledLetters("Zelda Breath")).isEmpty();
}
@Test
void words_SkipShortWordsAndStopAtTwo() {
assertThat(FuzzySearch.words("of the Legend Zelda Breath")).containsExactly("Legend", "Zelda");
}
@Test
void words_OneWord_HasNone() {
assertThat(FuzzySearch.words("Zelda")).isEmpty();
}
@Test
void distance_ComparesWithTheStartOfTheNameIgnoringCase() {
assertThat(FuzzySearch.distance("superr mario", "Super Mario Bros. 3")).isEqualTo(1);
assertThat(FuzzySearch.distance("mass efect", "Mass Effect")).isEqualTo(1);
assertThat(FuzzySearch.distance("mass efect", "Mass Destruction")).isGreaterThan(3);
}
@Test
void levenshtein_CountsInsertsDeletesAndChanges() {
assertThat(FuzzySearch.levenshtein("kitten", "sitting")).isEqualTo(3);
assertThat(FuzzySearch.levenshtein("", "abc")).isEqualTo(3);
assertThat(FuzzySearch.levenshtein("same", "same")).isZero();
}
}
@@ -17,6 +17,10 @@ import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ManualGameServiceTest extends AbstractUnitTest {
@@ -76,4 +80,93 @@ class ManualGameServiceTest extends AbstractUnitTest {
assertThat(result).isEqualTo(new ManualGameService.AddResult.Added(-119388L));
}
private static IgdbApiService.Game game(long igdbId, String name) {
return new IgdbApiService.Game(igdbId, name, 2000, null, List.of("Switch"), null);
}
@Test
void search_TypoWithADoubledLetter_FindsTheGameWithoutIt() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.searchGames("Superr Mario")).thenReturn(Optional.of(List.of()));
when(igdbApiService.searchGames("Super Mario")).thenReturn(Optional.of(List.of(
game(3, "Super Mario Bros. 3"), game(1, "Super Mario Bros."), game(2, "Super Mario 64"))));
List<String> names = service.search(USER_ID, "Superr Mario").orElseThrow().results().stream()
.map(hit -> hit.name()).toList();
assertThat(names).containsExactly("Super Mario Bros. 3", "Super Mario Bros.", "Super Mario 64");
}
@Test
void search_TypoInOneWord_SearchesTheWordsAloneAndPutsTheClosestNameFirst() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.searchGames(anyString())).thenReturn(Optional.of(List.of()));
when(igdbApiService.searchGames("Mass")).thenReturn(Optional.of(List.of(
game(1, "Mass Destruction"), game(2, "Mass Effect"))));
when(igdbApiService.searchGames("Efect")).thenReturn(Optional.empty());
List<String> names = service.search(USER_ID, "Mass Efect").orElseThrow().results().stream()
.map(hit -> hit.name()).toList();
assertThat(names).containsExactly("Mass Effect", "Mass Destruction");
}
@Test
void search_EnoughHits_AsksIgdbOnce() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.searchGames("zelda")).thenReturn(Optional.of(List.of(
game(1, "Zelda A"), game(2, "Zelda B"), game(3, "Zelda C"))));
assertThat(service.search(USER_ID, "zelda").orElseThrow().results()).hasSize(3);
verify(igdbApiService, times(1)).searchGames(anyString());
}
@Test
void search_TheGameIgdbFoundStaysBeforeTheTypoHits() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.searchGames("Breath of the Wild")).thenReturn(Optional.of(List.of(
game(1, "The Legend of Zelda: Breath of the Wild"))));
when(igdbApiService.searchGames("Breath")).thenReturn(Optional.of(List.of(
game(2, "Breath of Fire"), game(3, "Breath of Fire II"))));
List<String> names = service.search(USER_ID, "Breath of the Wild").orElseThrow().results().stream()
.map(hit -> hit.name()).toList();
assertThat(names).startsWith("The Legend of Zelda: Breath of the Wild");
}
@Test
void search_FewHits_KeepTheOrderOfTheSearch() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.searchGames(anyString())).thenReturn(Optional.of(List.of()));
when(igdbApiService.searchGames("zelda wild")).thenReturn(Optional.of(List.of(
game(1, "The Legend of Zelda: Breath of the Wild"), game(2, "Zelda: The Wand of Gamelon"))));
List<String> names = service.search(USER_ID, "zelda wild").orElseThrow().results().stream()
.map(hit -> hit.name()).toList();
assertThat(names).containsExactly("The Legend of Zelda: Breath of the Wild", "Zelda: The Wand of Gamelon");
}
@Test
void search_IgdbFailed_IsEmptyEvenWithoutAFallback() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.searchGames(anyString())).thenReturn(Optional.empty());
assertThat(service.search(USER_ID, "Superr Mario")).isEmpty();
}
@Test
void search_SomeHits_TriesNoDoubledLetterVariantAndAtMostTwoWords() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(igdbApiService.searchGames(anyString())).thenReturn(Optional.of(List.of()));
when(igdbApiService.searchGames("Hollow Knight Silksong")).thenReturn(Optional.of(List.of(game(1, "Hollow Knight"))));
service.search(USER_ID, "Hollow Knight Silksong");
verify(igdbApiService, never()).searchGames("Holow Knight Silksong");
verify(igdbApiService, never()).searchGames("Silksong");
verify(igdbApiService, times(3)).searchGames(anyString());
}
}