Public Access
feat(igdb): rank game search by popularity and help with typos
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:
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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user