Public Access
feat(speedrun): match speedrun.com games only when sure
Loose name matches gave wrong links. Now a link needs one sure hit: same name and within 1 year of IGDB, or one hit when no year is known. V47 marks old matches (not corrected ones) for one re-check. Refs #8
This commit is contained in:
1 parent
bb195a1f79
commit
9efed0ab1d
14 files changed
+601
-79
No files matched your search
@@ -67,10 +67,13 @@ To Be Played is split into three repositories:
|
|||||||
N["next up: random of top 5 in order"] --> M
|
N["next up: random of top 5 in order"] --> M
|
||||||
M --> O["games + reason"]
|
M --> O["games + reason"]
|
||||||
```
|
```
|
||||||
- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (and
|
- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (case,
|
||||||
a release year at most one year off, when IGDB knows it) with at least one verified run. The game detail returns
|
punctuation, ™ and roman numerals ignored) and a release year at most one year off, when IGDB knows it, with at
|
||||||
|
least one verified run. An edition that finds nothing tries its IGDB parent game with the parent's year, else its
|
||||||
|
name without an edition suffix. Several same-name games and no year to decide: no link. The game detail returns
|
||||||
the speedrun.com link as `speedrunUrl`. speedrun.com has no Steam or IGDB link and no key, so one queue works for
|
the speedrun.com link as `speedrunUrl`. speedrun.com has no Steam or IGDB link and no key, so one queue works for
|
||||||
all users, with up to 3 requests per game and 1 request per second; a result is kept 30 days.
|
all users, with up to 3 requests per game and 1 request per second; a result is kept 30 days. Migration V47 asks
|
||||||
|
every stored match again once at boot, behind user requests; links set by an auditor are never touched.
|
||||||
- **Achievements**: after every library sync a background job fetches the achievement schema of each owned game
|
- **Achievements**: after every library sync a background job fetches the achievement schema of each owned game
|
||||||
and the user's unlocks of each game whose playtime changed. One Steam call per second, so user requests keep half
|
and the user's unlocks of each game whose playtime changed. One Steam call per second, so user requests keep half
|
||||||
of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never
|
of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
|
|||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync
|
* The pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync
|
||||||
* asks again.
|
* asks again.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@@ -43,4 +43,14 @@ public class AsyncConfig {
|
|||||||
executor.setThreadNamePrefix("sync-follow-up-");
|
executor.setThreadNamePrefix("sync-follow-up-");
|
||||||
return executor;
|
return executor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One thread: speedrun.com has one rate limit and the queue has one drainer. The queue holds only drain triggers.
|
||||||
|
@Bean(name = BackgroundPools.SPEEDRUN)
|
||||||
|
public ThreadPoolTaskExecutor speedrunExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(1);
|
||||||
|
executor.setMaxPoolSize(1);
|
||||||
|
executor.setThreadNamePrefix("speedrun-");
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,7 @@ import java.util.Map;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.TreeSet;
|
import java.util.TreeSet;
|
||||||
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@@ -77,6 +78,9 @@ public class IgdbApiService {
|
|||||||
*/
|
*/
|
||||||
public record Game(long igdbId, String name, Integer year, String imageUrl, List<String> platforms, Long steamAppid) {}
|
public record Game(long igdbId, String name, Integer year, String imageUrl, List<String> platforms, Long steamAppid) {}
|
||||||
|
|
||||||
|
/** @param year null when IGDB knows no release date */
|
||||||
|
public record NameAndYear(String name, Integer year) {}
|
||||||
|
|
||||||
// IGDB's own order puts obscure entries first, so Core asks for more and sorts by popularity itself.
|
// 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_FETCH_LIMIT = 50;
|
||||||
private static final int SEARCH_LIMIT = 20;
|
private static final int SEARCH_LIMIT = 20;
|
||||||
@@ -382,18 +386,31 @@ public class IgdbApiService {
|
|||||||
* Like {@link #getReleaseDates}, but for IGDB game ids.
|
* Like {@link #getReleaseDates}, but for IGDB game ids.
|
||||||
*/
|
*/
|
||||||
public Optional<Map<Long, LocalDate>> getReleaseDatesByGameIds(Collection<Long> igdbIds) {
|
public Optional<Map<Long, LocalDate>> getReleaseDatesByGameIds(Collection<Long> igdbIds) {
|
||||||
|
return inBatches(igdbIds, "release dates", this::datesOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name and release year of IGDB games by id. Games IGDB does not know, or without a name, are missing. Empty
|
||||||
|
* when the adapter is off, nothing was asked or a request failed.
|
||||||
|
*/
|
||||||
|
public Optional<Map<Long, NameAndYear>> getNamesAndYears(Collection<Long> igdbIds) {
|
||||||
|
return inBatches(igdbIds, "game names", this::namesAndYearsOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> Optional<Map<Long, T>> inBatches(Collection<Long> igdbIds, String what,
|
||||||
|
Function<List<Long>, Map<Long, T>> batchQuery) {
|
||||||
if (!isEnabled() || igdbIds.isEmpty()) {
|
if (!isEnabled() || igdbIds.isEmpty()) {
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
List<Long> ids = List.copyOf(igdbIds);
|
List<Long> ids = List.copyOf(igdbIds);
|
||||||
Map<Long, LocalDate> result = new HashMap<>();
|
Map<Long, T> result = new HashMap<>();
|
||||||
for (int from = 0; from < ids.size(); from += BATCH) {
|
for (int from = 0; from < ids.size(); from += BATCH) {
|
||||||
result.putAll(datesOf(ids.subList(from, Math.min(from + BATCH, ids.size()))));
|
result.putAll(batchQuery.apply(ids.subList(from, Math.min(from + BATCH, ids.size()))));
|
||||||
}
|
}
|
||||||
return Optional.of(result);
|
return Optional.of(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Error communicating with IGDB for release dates", e);
|
log.error("Error communicating with IGDB for {}", what, e);
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -601,10 +618,7 @@ public class IgdbApiService {
|
|||||||
if (igdbIds.isEmpty()) {
|
if (igdbIds.isEmpty()) {
|
||||||
return Map.of();
|
return Map.of();
|
||||||
}
|
}
|
||||||
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
|
IgdbResponses.Game[] games = gamesById(igdbIds, "first_release_date");
|
||||||
IgdbResponses.Game[] games = query("/games",
|
|
||||||
"fields first_release_date; where id = (" + gameIds + "); limit " + LIMIT + ";",
|
|
||||||
IgdbResponses.Game[].class);
|
|
||||||
Map<Long, LocalDate> result = new HashMap<>();
|
Map<Long, LocalDate> result = new HashMap<>();
|
||||||
for (IgdbResponses.Game game : games) {
|
for (IgdbResponses.Game game : games) {
|
||||||
if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) {
|
if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) {
|
||||||
@@ -614,6 +628,23 @@ public class IgdbApiService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Map<Long, NameAndYear> namesAndYearsOf(Collection<Long> igdbIds) {
|
||||||
|
Map<Long, NameAndYear> result = new HashMap<>();
|
||||||
|
for (IgdbResponses.Game game : gamesById(igdbIds, "name,first_release_date")) {
|
||||||
|
String name = trimmedName(game);
|
||||||
|
if (game.id() != null && igdbIds.contains(game.id()) && !name.isEmpty()) {
|
||||||
|
result.put(game.id(), new NameAndYear(name, game.firstReleaseDate() == null ? null : year(game.firstReleaseDate())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IgdbResponses.Game[] gamesById(Collection<Long> igdbIds, String fields) {
|
||||||
|
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
|
||||||
|
return query("/games", "fields " + fields + "; where id = (" + gameIds + "); limit " + LIMIT + ";",
|
||||||
|
IgdbResponses.Game[].class);
|
||||||
|
}
|
||||||
|
|
||||||
private static int year(long unixSeconds) {
|
private static int year(long unixSeconds) {
|
||||||
return date(unixSeconds).getYear();
|
return date(unixSeconds).getYear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public interface GameSpeedrunRepository extends JpaRepository<GameSpeedrun, Long> {
|
public interface GameSpeedrunRepository extends JpaRepository<GameSpeedrun, Long> {
|
||||||
@@ -48,4 +49,12 @@ public interface GameSpeedrunRepository extends JpaRepository<GameSpeedrun, Long
|
|||||||
@Modifying
|
@Modifying
|
||||||
@Query("update GameSpeedrun s set s.checkedAt = :longAgo where s.appid = :appid")
|
@Query("update GameSpeedrun s set s.checkedAt = :longAgo where s.appid = :appid")
|
||||||
void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo);
|
void markStale(@Param("appid") Long appid, @Param("longAgo") Instant longAgo);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows marked for a new check (checked_at <= before) of games some user lists, without an auditor's link.
|
||||||
|
*/
|
||||||
|
@Query(value = "select appid from game_speedrun where checked_at <= :before and not " + CORRECTED
|
||||||
|
+ " and exists (select 1 from listed_user_game ug where ug.appid = game_speedrun.appid) order by appid",
|
||||||
|
nativeQuery = true)
|
||||||
|
List<Long> findAppidsToRecheck(@Param("before") Instant before);
|
||||||
}
|
}
|
||||||
@@ -6,21 +6,33 @@ import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finds a library game among speedrun.com's search hits. speedrun.com has no Steam or IGDB link, so the name must be
|
* Finds a library game among speedrun.com's search hits, or none: a wrong link is worse than no link. speedrun.com has
|
||||||
* the same and, when both years are known, the release year may differ by at most one.
|
* no Steam or IGDB link, so the name must be the same (case, punctuation, trademark signs and roman numerals do not
|
||||||
|
* count). With known years the year of the hit must be within one of ours. Without a year to decide, only a single
|
||||||
|
* same-name hit counts.
|
||||||
*/
|
*/
|
||||||
final class SpeedrunMatch {
|
final class SpeedrunMatch {
|
||||||
|
|
||||||
static final int MAX_CANDIDATES = 2;
|
|
||||||
static final String WEBLINK_PREFIX = "https://www.speedrun.com/";
|
static final String WEBLINK_PREFIX = "https://www.speedrun.com/";
|
||||||
|
|
||||||
// speedrun.com tells remakes apart by the year: "Prey (2017)" next to "Prey".
|
// speedrun.com tells remakes apart by the year: "Prey (2017)" next to "Prey".
|
||||||
private static final Pattern YEAR_SUFFIX = Pattern.compile("^(.*?)\\s*\\((\\d{4})\\)$");
|
private static final Pattern YEAR_SUFFIX = Pattern.compile("^(.*?)\\s*\\((\\d{4})\\)$");
|
||||||
|
// Other pages on speedrun.com ("Remastered", "Remake") are left alone on purpose.
|
||||||
|
private static final Pattern EDITION_SUFFIX = Pattern.compile(
|
||||||
|
"\\s*[-:\u2013]?\\s*(?:the\\s+)?(?:(?:game of the year|goty|special|definitive|complete|deluxe|digital deluxe|enhanced|gold|"
|
||||||
|
+ "ultimate|anniversary|legendary|premium) edition|goty|director'?s cut)\\s*$",
|
||||||
|
Pattern.CASE_INSENSITIVE);
|
||||||
|
// Index + 2 is the number.
|
||||||
|
private static final List<String> ROMAN_NUMERALS = List.of("ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x",
|
||||||
|
"xi", "xii", "xiii", "xiv", "xv", "xvi", "xvii", "xviii", "xix", "xx");
|
||||||
|
// "i" and "x" stay: "Mega Man X" is not "Mega Man 10".
|
||||||
|
private static final Pattern ROMAN_NUMERAL = Pattern.compile("\\b(?!x\\b)(" + String.join("|", ROMAN_NUMERALS) + ")\\b");
|
||||||
// The column sizes of game_speedrun.
|
// The column sizes of game_speedrun.
|
||||||
private static final int MAX_GAME_ID_LENGTH = 16;
|
private static final int MAX_GAME_ID_LENGTH = 16;
|
||||||
private static final int MAX_WEBLINK_LENGTH = 512;
|
private static final int MAX_WEBLINK_LENGTH = 512;
|
||||||
@@ -39,23 +51,40 @@ final class SpeedrunMatch {
|
|||||||
/**
|
/**
|
||||||
* @param years our release years of the game; empty when unknown
|
* @param years our release years of the game; empty when unknown
|
||||||
* @param hits in speedrun.com's order
|
* @param hits in speedrun.com's order
|
||||||
* @return the hits worth asking for runs, best first, at most {@link #MAX_CANDIDATES}
|
* @return the one hit that is this game; empty when no hit is, or when several could be
|
||||||
*/
|
*/
|
||||||
static List<SpeedrunApiService.Game> candidates(String name, Collection<Integer> years, List<SpeedrunApiService.Game> hits) {
|
static Optional<SpeedrunApiService.Game> sure(String name, Collection<Integer> years, List<SpeedrunApiService.Game> hits) {
|
||||||
NameAndYear ours = split(name);
|
NameAndYear ours = split(name);
|
||||||
Set<Integer> ourYears = new HashSet<>(years);
|
Set<Integer> ourYears = new HashSet<>(years);
|
||||||
if (ours.year() != null) ourYears.add(ours.year());
|
if (ours.year() != null) ourYears.add(ours.year());
|
||||||
return hits.stream()
|
List<SpeedrunApiService.Game> same = hits.stream()
|
||||||
.filter(hit -> hit.id() != null && hit.id().length() <= MAX_GAME_ID_LENGTH)
|
.filter(hit -> hit.id() != null && hit.id().length() <= MAX_GAME_ID_LENGTH)
|
||||||
.filter(hit -> isWeblink(hit.weblink()))
|
.filter(hit -> isWeblink(hit.weblink()))
|
||||||
.filter(hit -> {
|
.filter(hit -> split(hit.name()).name().equals(ours.name()))
|
||||||
NameAndYear theirs = split(hit.name());
|
|
||||||
Integer year = theirs.year() != null ? theirs.year() : hit.year();
|
|
||||||
return theirs.name().equals(ours.name()) && (year == null || ourYears.isEmpty()
|
|
||||||
|| ourYears.stream().anyMatch(ourYear -> Math.abs(ourYear - year) <= 1));
|
|
||||||
})
|
|
||||||
.limit(MAX_CANDIDATES)
|
|
||||||
.toList();
|
.toList();
|
||||||
|
if (ourYears.isEmpty()) {
|
||||||
|
return only(same);
|
||||||
|
}
|
||||||
|
List<SpeedrunApiService.Game> dated = same.stream()
|
||||||
|
.filter(hit -> {
|
||||||
|
Integer year = yearOf(hit);
|
||||||
|
return year != null && ourYears.stream().anyMatch(ourYear -> Math.abs(ourYear - year) <= 1);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
if (!dated.isEmpty()) {
|
||||||
|
return only(dated);
|
||||||
|
}
|
||||||
|
return only(same).filter(hit -> yearOf(hit) == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the name without "Special Edition", "Director's Cut" and the like; empty when it has no such suffix or
|
||||||
|
* nothing is left
|
||||||
|
*/
|
||||||
|
static Optional<String> withoutEditionSuffix(String name) {
|
||||||
|
String trimmed = name.trim();
|
||||||
|
String stripped = EDITION_SUFFIX.matcher(trimmed).replaceFirst("").trim();
|
||||||
|
return stripped.isEmpty() || stripped.equals(trimmed) ? Optional.empty() : Optional.of(stripped);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,11 +94,25 @@ final class SpeedrunMatch {
|
|||||||
return name.replaceAll(GameNames.TRADEMARK_SIGNS, "").trim();
|
return name.replaceAll(GameNames.TRADEMARK_SIGNS, "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Optional<SpeedrunApiService.Game> only(List<SpeedrunApiService.Game> games) {
|
||||||
|
return games.size() == 1 ? Optional.of(games.get(0)) : Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer yearOf(SpeedrunApiService.Game hit) {
|
||||||
|
Integer fromName = split(hit.name()).year();
|
||||||
|
return fromName != null ? fromName : hit.year();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String key(String name) {
|
||||||
|
return ROMAN_NUMERAL.matcher(GameNames.normalize(name)).replaceAll(
|
||||||
|
match -> String.valueOf(ROMAN_NUMERALS.indexOf(match.group()) + 2));
|
||||||
|
}
|
||||||
|
|
||||||
private static NameAndYear split(String name) {
|
private static NameAndYear split(String name) {
|
||||||
Matcher matcher = YEAR_SUFFIX.matcher(name.trim());
|
Matcher matcher = YEAR_SUFFIX.matcher(name.trim());
|
||||||
return matcher.matches()
|
return matcher.matches()
|
||||||
? new NameAndYear(GameNames.normalize(matcher.group(1)), Integer.parseInt(matcher.group(2)))
|
? new NameAndYear(key(matcher.group(1)), Integer.parseInt(matcher.group(2)))
|
||||||
: new NameAndYear(GameNames.normalize(name), null);
|
: new NameAndYear(key(name), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private record NameAndYear(String name, Integer year) {}
|
private record NameAndYear(String name, Integer year) {}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package core_service.modules.library.service;
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
import core_service.modules.igdb_adapter.service.IgdbApiService;
|
import core_service.modules.igdb_adapter.service.IgdbApiService;
|
||||||
|
import core_service.modules.library.domain.GameEdition;
|
||||||
import core_service.modules.library.domain.GameKeys;
|
import core_service.modules.library.domain.GameKeys;
|
||||||
import core_service.modules.library.domain.Mood;
|
import core_service.modules.library.domain.Mood;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.repo.GameEditionRepository;
|
||||||
import core_service.modules.library.repo.GameSpeedrunRepository;
|
import core_service.modules.library.repo.GameSpeedrunRepository;
|
||||||
import core_service.modules.library.repo.SteamGameRepository;
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
@@ -12,6 +14,8 @@ import core_service.shared.background.BackgroundPools;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.core.task.TaskExecutor;
|
import org.springframework.core.task.TaskExecutor;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -32,7 +36,14 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
|||||||
/**
|
/**
|
||||||
* Stores which games have a speedrun.com page with verified runs, for {@link Mood#SPEEDRUN} and the link on the
|
* Stores which games have a speedrun.com page with verified runs, for {@link Mood#SPEEDRUN} and the link on the
|
||||||
* detail page. speedrun.com is searched by name, one game at a time, so all callers share one queue and one thread
|
* detail page. speedrun.com is searched by name, one game at a time, so all callers share one queue and one thread
|
||||||
* works it off: a game in several backlogs is asked once, and the rate limit has one waiter.
|
* (the speedrun executor) works it off: a game in several backlogs is asked once, and the rate limit has one waiter.
|
||||||
|
* <p>
|
||||||
|
* A game is looked up by its own name and years; when that gives no sure hit, an edition is looked up by the name and
|
||||||
|
* year of its IGDB parent game, a game without a parent by its name without an edition suffix. A sure hit without
|
||||||
|
* verified runs ends the lookup. See {@link SpeedrunMatch} for "sure".
|
||||||
|
* <p>
|
||||||
|
* {@link #recheckMarked} asks the rows a migration marked (checked_at = epoch) again. Those games wait behind every
|
||||||
|
* game a user asked for.
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -42,27 +53,53 @@ public class SpeedrunService {
|
|||||||
static final int BATCH = 50;
|
static final int BATCH = 50;
|
||||||
// speedrun.com is down or blocks us; the rest of the queue is asked on the next trigger.
|
// speedrun.com is down or blocks us; the rest of the queue is asked on the next trigger.
|
||||||
static final int MAX_ERRORS_IN_A_ROW = 3;
|
static final int MAX_ERRORS_IN_A_ROW = 3;
|
||||||
|
// A small batch, so a user request waits for a few games and not for 50.
|
||||||
|
static final int RECHECK_BATCH = 10;
|
||||||
|
|
||||||
private final SpeedrunApiService speedrunApiService;
|
private final SpeedrunApiService speedrunApiService;
|
||||||
private final IgdbApiService igdbApiService;
|
private final IgdbApiService igdbApiService;
|
||||||
private final GameSpeedrunRepository gameSpeedrunRepository;
|
private final GameSpeedrunRepository gameSpeedrunRepository;
|
||||||
|
private final GameEditionRepository gameEditionRepository;
|
||||||
private final SteamGameRepository steamGameRepository;
|
private final SteamGameRepository steamGameRepository;
|
||||||
private final UserGameRepository userGameRepository;
|
private final UserGameRepository userGameRepository;
|
||||||
private final TaskExecutor syncFollowUpExecutor;
|
private final TaskExecutor syncFollowUpExecutor;
|
||||||
|
// Works both queues off on its own thread, so the follow-up pool never waits for speedrun.com.
|
||||||
|
private final TaskExecutor speedrunExecutor;
|
||||||
|
|
||||||
private final ConcurrentSkipListSet<Long> pending = new ConcurrentSkipListSet<>();
|
private final ConcurrentSkipListSet<Long> pending = new ConcurrentSkipListSet<>();
|
||||||
|
private final ConcurrentSkipListSet<Long> recheck = new ConcurrentSkipListSet<>();
|
||||||
private final AtomicBoolean running = new AtomicBoolean();
|
private final AtomicBoolean running = new AtomicBoolean();
|
||||||
|
|
||||||
@Value("${app.library.speedrun-ttl-days:30}")
|
@Value("${app.library.speedrun-ttl-days:30}")
|
||||||
private long ttlDays;
|
private long ttlDays;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queues the games without a fresh row and, unless another thread already does it, works the queue off on this
|
* Queues the games without a fresh row and returns; the speedrun thread works the queue off. A game takes up to 3
|
||||||
* thread. A game takes up to 3 requests, and there is 1 request per second.
|
* requests, and there is 1 request per second.
|
||||||
*/
|
*/
|
||||||
public void refresh(Collection<Long> appids) {
|
public void refresh(Collection<Long> appids) {
|
||||||
if (!speedrunApiService.isEnabled() || appids.isEmpty()) return;
|
if (!speedrunApiService.isEnabled() || appids.isEmpty()) return;
|
||||||
pending.addAll(appids);
|
pending.addAll(appids);
|
||||||
|
speedrunExecutor.execute(this::work);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues the rows the V47 migration marked, behind the games users asked for.
|
||||||
|
*/
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void recheckMarked() {
|
||||||
|
if (!speedrunApiService.isEnabled()) return;
|
||||||
|
syncFollowUpExecutor.execute(() -> Refreshes.quietly("Speedrun re-check", () -> {
|
||||||
|
List<Long> marked = gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH);
|
||||||
|
if (marked.isEmpty()) return;
|
||||||
|
log.info("Speedrun re-check of {} games", marked.size());
|
||||||
|
recheck.addAll(marked);
|
||||||
|
speedrunExecutor.execute(this::work);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void work() {
|
||||||
|
// Another work() is draining: it also takes what was queued meanwhile.
|
||||||
while (running.compareAndSet(false, true)) {
|
while (running.compareAndSet(false, true)) {
|
||||||
try {
|
try {
|
||||||
drain();
|
drain();
|
||||||
@@ -70,7 +107,7 @@ public class SpeedrunService {
|
|||||||
running.set(false);
|
running.set(false);
|
||||||
}
|
}
|
||||||
// Games can be queued after the last poll and before the flag was cleared.
|
// Games can be queued after the last poll and before the flag was cleared.
|
||||||
if (pending.isEmpty()) return;
|
if (pending.isEmpty() && recheck.isEmpty()) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,12 +136,9 @@ public class SpeedrunService {
|
|||||||
|
|
||||||
private void drain() {
|
private void drain() {
|
||||||
int errorsInARow = 0;
|
int errorsInARow = 0;
|
||||||
while (!pending.isEmpty()) {
|
while (!pending.isEmpty() || !recheck.isEmpty()) {
|
||||||
List<Long> batch = new ArrayList<>();
|
boolean forUsers = !pending.isEmpty();
|
||||||
Long next;
|
List<Long> batch = forUsers ? poll(pending, BATCH) : poll(recheck, RECHECK_BATCH);
|
||||||
while (batch.size() < BATCH && (next = pending.pollFirst()) != null) {
|
|
||||||
batch.add(next);
|
|
||||||
}
|
|
||||||
batch.removeAll(gameSpeedrunRepository.findFreshAppids(batch, Instant.now().minus(Duration.ofDays(ttlDays))));
|
batch.removeAll(gameSpeedrunRepository.findFreshAppids(batch, Instant.now().minus(Duration.ofDays(ttlDays))));
|
||||||
if (batch.isEmpty()) continue;
|
if (batch.isEmpty()) continue;
|
||||||
|
|
||||||
@@ -113,17 +147,24 @@ public class SpeedrunService {
|
|||||||
names.put(game.getAppid(), game.getName());
|
names.put(game.getAppid(), game.getName());
|
||||||
}
|
}
|
||||||
Optional<Map<Long, Set<Integer>>> years = releaseYears(batch);
|
Optional<Map<Long, Set<Integer>>> years = releaseYears(batch);
|
||||||
if (years.isEmpty()) {
|
Optional<Map<Long, IgdbApiService.NameAndYear>> parents = years.isEmpty() ? Optional.empty() : parentNames(batch);
|
||||||
|
if (parents.isEmpty()) {
|
||||||
// Matching by name only would store a wrong remake for 30 days.
|
// Matching by name only would store a wrong remake for 30 days.
|
||||||
log.warn("Speedrun games of {} not refreshed, IGDB did not answer", batch.size());
|
log.warn("Speedrun games of {} not refreshed, IGDB did not answer", batch.size());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (Long appid : batch) {
|
for (int i = 0; i < batch.size(); i++) {
|
||||||
|
if (!forUsers && !pending.isEmpty()) {
|
||||||
|
// A user asked in the meantime: the rest of the re-check goes behind it.
|
||||||
|
recheck.addAll(batch.subList(i, batch.size()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Long appid = batch.get(i);
|
||||||
String name = names.get(appid);
|
String name = names.get(appid);
|
||||||
if (name == null || name.equals(SteamGame.placeholderName(appid))) continue;
|
if (name == null || name.equals(SteamGame.placeholderName(appid))) continue;
|
||||||
boolean stored;
|
boolean stored;
|
||||||
try {
|
try {
|
||||||
stored = checkAndStore(appid, name, years.get().getOrDefault(appid, Set.of()));
|
stored = checkAndStore(appid, name, years.get().getOrDefault(appid, Set.of()), parents.get().get(appid));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Speedrun game of appid={} not stored: {}", appid, e.getMessage());
|
log.warn("Speedrun game of appid={} not stored: {}", appid, e.getMessage());
|
||||||
stored = false;
|
stored = false;
|
||||||
@@ -133,31 +174,78 @@ public class SpeedrunService {
|
|||||||
} else if (++errorsInARow >= MAX_ERRORS_IN_A_ROW) {
|
} else if (++errorsInARow >= MAX_ERRORS_IN_A_ROW) {
|
||||||
log.warn("Speedrun check failed {} times in a row, {} queued games dropped", errorsInARow, pending.size());
|
log.warn("Speedrun check failed {} times in a row, {} queued games dropped", errorsInARow, pending.size());
|
||||||
pending.clear();
|
pending.clear();
|
||||||
|
recheck.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<Long> poll(ConcurrentSkipListSet<Long> queue, int size) {
|
||||||
|
List<Long> batch = new ArrayList<>();
|
||||||
|
Long next;
|
||||||
|
while (batch.size() < size && (next = queue.pollFirst()) != null) {
|
||||||
|
batch.add(next);
|
||||||
|
}
|
||||||
|
return batch;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param parent the IGDB game this one is an edition, remaster or port of; null when there is none
|
||||||
* @return false when a speedrun.com call failed; then nothing is stored and the game is asked again later
|
* @return false when a speedrun.com call failed; then nothing is stored and the game is asked again later
|
||||||
*/
|
*/
|
||||||
private boolean checkAndStore(Long appid, String name, Set<Integer> years) {
|
private boolean checkAndStore(Long appid, String name, Set<Integer> years, IgdbApiService.NameAndYear parent) {
|
||||||
Optional<List<SpeedrunApiService.Game>> hits = speedrunApiService.findGames(SpeedrunMatch.searchText(name));
|
|
||||||
if (hits.isEmpty()) return false;
|
|
||||||
SpeedrunApiService.Game match = null;
|
SpeedrunApiService.Game match = null;
|
||||||
for (SpeedrunApiService.Game candidate : SpeedrunMatch.candidates(name, years, hits.get())) {
|
for (Lookup lookup : lookups(name, years, parent)) {
|
||||||
Optional<Boolean> hasRuns = speedrunApiService.hasVerifiedRuns(candidate.id());
|
Optional<List<SpeedrunApiService.Game>> hits = speedrunApiService.findGames(SpeedrunMatch.searchText(lookup.name()));
|
||||||
|
if (hits.isEmpty()) return false;
|
||||||
|
Optional<SpeedrunApiService.Game> sure = SpeedrunMatch.sure(lookup.name(), lookup.years(), hits.get());
|
||||||
|
if (sure.isEmpty()) continue;
|
||||||
|
Optional<Boolean> hasRuns = speedrunApiService.hasVerifiedRuns(sure.get().id());
|
||||||
if (hasRuns.isEmpty()) return false;
|
if (hasRuns.isEmpty()) return false;
|
||||||
if (hasRuns.get()) {
|
if (hasRuns.get()) match = sure.get();
|
||||||
match = candidate;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
gameSpeedrunRepository.upsert(appid, match == null ? null : match.id(), match == null ? null : match.weblink(), Instant.now());
|
gameSpeedrunRepository.upsert(appid, match == null ? null : match.id(), match == null ? null : match.weblink(), Instant.now());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<Lookup> lookups(String name, Set<Integer> years, IgdbApiService.NameAndYear parent) {
|
||||||
|
List<Lookup> lookups = new ArrayList<>();
|
||||||
|
lookups.add(new Lookup(name, years));
|
||||||
|
if (parent != null && !SpeedrunMatch.searchText(parent.name()).equalsIgnoreCase(SpeedrunMatch.searchText(name))) {
|
||||||
|
lookups.add(new Lookup(parent.name(), parent.year() == null ? Set.of() : Set.of(parent.year())));
|
||||||
|
} else {
|
||||||
|
SpeedrunMatch.withoutEditionSuffix(name).ifPresent(plain -> lookups.add(new Lookup(plain, years)));
|
||||||
|
}
|
||||||
|
return lookups;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Lookup(String name, Set<Integer> years) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return library key to the IGDB game it is an edition, remaster or port of; a game that is its own same game
|
||||||
|
* has no parent. Empty when IGDB failed.
|
||||||
|
*/
|
||||||
|
private Optional<Map<Long, IgdbApiService.NameAndYear>> parentNames(List<Long> appids) {
|
||||||
|
Map<Long, List<Long>> appidsByParent = new HashMap<>();
|
||||||
|
for (GameEdition edition : gameEditionRepository.findAllById(appids)) {
|
||||||
|
if (edition.getIgdbId() != null && edition.getSameGameId() != null
|
||||||
|
&& !edition.getSameGameId().equals(edition.getIgdbId())) {
|
||||||
|
appidsByParent.computeIfAbsent(edition.getSameGameId(), id -> new ArrayList<>()).add(edition.getAppid());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (appidsByParent.isEmpty() || !igdbApiService.isEnabled()) {
|
||||||
|
return Optional.of(Map.of());
|
||||||
|
}
|
||||||
|
Optional<Map<Long, IgdbApiService.NameAndYear>> found = igdbApiService.getNamesAndYears(appidsByParent.keySet());
|
||||||
|
if (found.isEmpty()) return Optional.empty();
|
||||||
|
Map<Long, IgdbApiService.NameAndYear> parents = new HashMap<>();
|
||||||
|
found.get().forEach((parentId, parent) ->
|
||||||
|
appidsByParent.get(parentId).forEach(appid -> parents.put(appid, parent)));
|
||||||
|
return Optional.of(parents);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return empty when IGDB failed. A game IGDB does not know, and every game when IGDB is off, has no years and is
|
* @return empty when IGDB failed. A game IGDB does not know, and every game when IGDB is off, has no years and is
|
||||||
* matched by name only.
|
* matched by name only.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package core_service.shared.background;
|
|||||||
import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
|
import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The bean names of the two background pools (configuration.AsyncConfig). Every {@code @Async} names one of them:
|
* The bean names of the background pools (configuration.AsyncConfig). Every {@code @Async} names one of them:
|
||||||
* with the scheduler there are two TaskExecutor beans, and a bare {@code @Async} would run unbounded.
|
* with the scheduler there are two TaskExecutor beans, and a bare {@code @Async} would run unbounded.
|
||||||
*/
|
*/
|
||||||
public final class BackgroundPools {
|
public final class BackgroundPools {
|
||||||
@@ -12,11 +12,16 @@ public final class BackgroundPools {
|
|||||||
public static final String DEFAULT = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME;
|
public static final String DEFAULT = TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The achievement runs after a sync and every speedrun refresh: they hold a thread for minutes (Steam and
|
* The achievement runs after a sync: they hold a thread for minutes (Steam pace), so page views keep the default pool. Small tasks, nothing is dropped.
|
||||||
* speedrun.com pace), so page views keep the default pool. Small tasks, nothing is dropped.
|
|
||||||
*/
|
*/
|
||||||
public static final String SYNC_FOLLOW_UP = "syncFollowUpExecutor";
|
public static final String SYNC_FOLLOW_UP = "syncFollowUpExecutor";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one thread that works the speedrun queue off (speedrun.com pace, a re-check can take an hour). Other tasks
|
||||||
|
* never wait for it.
|
||||||
|
*/
|
||||||
|
public static final String SPEEDRUN = "speedrunExecutor";
|
||||||
|
|
||||||
private BackgroundPools() {
|
private BackgroundPools() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- New speedrun.com matching rules (#8): every match not set by an auditor is asked again once. checked_at = epoch is
|
||||||
|
-- the marker SpeedrunService.recheckMarked queues at boot; the link stays until speedrun.com answers.
|
||||||
|
UPDATE game_speedrun s SET checked_at = 'epoch'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM game_correction c WHERE c.appid = s.appid AND c.field = 'SPEEDRUN');
|
||||||
@@ -4,6 +4,8 @@ import core_service.shared.test.AbstractUnitTest;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
@@ -45,6 +47,27 @@ class AsyncConfigTest extends AbstractUnitTest {
|
|||||||
assertFalse(droppedRan.get());
|
assertFalse(droppedRan.get());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void speedrunPool_RunsOnOneNamedThread() throws InterruptedException {
|
||||||
|
ThreadPoolTaskExecutor executor = new AsyncConfig().speedrunExecutor();
|
||||||
|
executor.initialize();
|
||||||
|
CountDownLatch done = new CountDownLatch(2);
|
||||||
|
Set<String> threads = ConcurrentHashMap.newKeySet();
|
||||||
|
try {
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
executor.execute(() -> {
|
||||||
|
threads.add(Thread.currentThread().getName());
|
||||||
|
done.countDown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(done.await(5, TimeUnit.SECONDS));
|
||||||
|
} finally {
|
||||||
|
executor.shutdown();
|
||||||
|
}
|
||||||
|
assertEquals(Set.of("speedrun-1"), threads);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void followUpPool_KeepsEveryTask() throws InterruptedException {
|
void followUpPool_KeepsEveryTask() throws InterruptedException {
|
||||||
ThreadPoolTaskExecutor executor = new AsyncConfig().syncFollowUpExecutor();
|
ThreadPoolTaskExecutor executor = new AsyncConfig().syncFollowUpExecutor();
|
||||||
|
|||||||
@@ -279,6 +279,28 @@ class IgdbApiServiceTest extends AbstractUnitTest {
|
|||||||
assertThat(service.getReleaseDatesByGameIds(List.of(1025L, 1026L))).contains(Map.of(1025L, LocalDate.of(2017, 5, 5)));
|
assertThat(service.getReleaseDatesByGameIds(List.of(1025L, 1026L))).contains(Map.of(1025L, LocalDate.of(2017, 5, 5)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNamesAndYears_MapsTheIgdbIdToNameAndYear() {
|
||||||
|
expectToken();
|
||||||
|
igdb.expect(requestTo(containsString("/games")))
|
||||||
|
.andExpect(content().string(containsString("fields name,first_release_date; where id = (1025,1026,1027)")))
|
||||||
|
.andRespond(withSuccess("[{\"id\": 1025, \"name\": \" Skyrim \", \"first_release_date\": 1321833600},"
|
||||||
|
+ " {\"id\": 1026, \"name\": \"Undated\"}, {\"id\": 1027, \"name\": \" \"}, {\"id\": 9, \"name\": \"Unasked\"}]",
|
||||||
|
MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
assertThat(service.getNamesAndYears(List.of(1025L, 1026L, 1027L))).contains(Map.of(
|
||||||
|
1025L, new IgdbApiService.NameAndYear("Skyrim", 2011),
|
||||||
|
1026L, new IgdbApiService.NameAndYear("Undated", null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNamesAndYears_IgdbError_IsEmpty() {
|
||||||
|
expectToken();
|
||||||
|
igdb.expect(requestTo(containsString("/games"))).andRespond(withServerError());
|
||||||
|
|
||||||
|
assertThat(service.getNamesAndYears(List.of(1025L))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getReleaseDates_IgdbError_IsEmpty() {
|
void getReleaseDates_IgdbError_IsEmpty() {
|
||||||
expectToken();
|
expectToken();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package core_service.modules.library.repo;
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
import core_service.modules.library.domain.GameSpeedrun;
|
import core_service.modules.library.domain.GameSpeedrun;
|
||||||
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.shared.test.AbstractPersistenceTest;
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
import jakarta.persistence.EntityManager;
|
import jakarta.persistence.EntityManager;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -22,8 +24,15 @@ class GameSpeedrunRepositoryTest extends AbstractPersistenceTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private EntityManager entityManager;
|
private EntityManager entityManager;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void clean() {
|
void clean() {
|
||||||
|
userGameRepository.deleteAll();
|
||||||
gameSpeedrunRepository.deleteAll();
|
gameSpeedrunRepository.deleteAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,4 +73,35 @@ class GameSpeedrunRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertThat(row.getSpeedrunGameId()).isNull();
|
assertThat(row.getSpeedrunGameId()).isNull();
|
||||||
assertThat(row.getCheckedAt()).isAfter(Instant.EPOCH);
|
assertThat(row.getCheckedAt()).isAfter(Instant.EPOCH);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void list(long appid) {
|
||||||
|
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
|
||||||
|
userGameRepository.saveAndFlush(new UserGame(1L, appid, 0, Instant.now(), Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAppidsToRecheck_ListedMarkedRowsOnly() {
|
||||||
|
gameSpeedrunRepository.writeWeblink(620L, "https://www.speedrun.com/portal", Instant.EPOCH);
|
||||||
|
gameSpeedrunRepository.upsert(220L, null, null, Instant.now());
|
||||||
|
gameSpeedrunRepository.writeWeblink(730L, null, Instant.EPOCH);
|
||||||
|
gameSpeedrunRepository.writeWeblink(440L, null, Instant.EPOCH);
|
||||||
|
entityManager.createNativeQuery("insert into game_correction (appid, field, value, source_value, corrected_by, "
|
||||||
|
+ "corrected_at) values (730, 'SPEEDRUN', 'null', 'null', 7, now())").executeUpdate();
|
||||||
|
list(620);
|
||||||
|
list(220);
|
||||||
|
list(730);
|
||||||
|
|
||||||
|
assertThat(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).containsExactly(620L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAppidsToRecheck_RowAnsweredOnce_IsNotPickedAgain() {
|
||||||
|
gameSpeedrunRepository.writeWeblink(620L, null, Instant.EPOCH);
|
||||||
|
list(620);
|
||||||
|
assertThat(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).containsExactly(620L);
|
||||||
|
|
||||||
|
gameSpeedrunRepository.upsert(620L, null, null, Instant.now());
|
||||||
|
|
||||||
|
assertThat(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).isEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -18,48 +18,95 @@ class SpeedrunMatchTest extends AbstractUnitTest {
|
|||||||
private static final SpeedrunApiService.Game PREY_2006 = hit("prey", "Prey", 2006);
|
private static final SpeedrunApiService.Game PREY_2006 = hit("prey", "Prey", 2006);
|
||||||
private static final SpeedrunApiService.Game PREY_2017 = hit("prey2017", "Prey (2017)", 2017);
|
private static final SpeedrunApiService.Game PREY_2017 = hit("prey2017", "Prey (2017)", 2017);
|
||||||
|
|
||||||
@Test
|
private static List<String> ids(String name, Set<Integer> years, List<SpeedrunApiService.Game> hits) {
|
||||||
void candidates_YearSuffixTellsTheRemakeApart() {
|
return SpeedrunMatch.sure(name, years, hits).map(SpeedrunApiService.Game::id).stream().toList();
|
||||||
assertThat(SpeedrunMatch.candidates("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2017);
|
|
||||||
assertThat(SpeedrunMatch.candidates("Prey", Set.of(2006), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2006);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void candidates_UnknownYear_KeepsSpeedrunOrder() {
|
void sure_IgdbYearPicksTheRemake() {
|
||||||
assertThat(SpeedrunMatch.candidates("Prey", Set.of(), List.of(PREY_2006, PREY_2017))).containsExactly(PREY_2006, PREY_2017);
|
assertThat(SpeedrunMatch.sure("Prey", Set.of(2017), List.of(PREY_2006, PREY_2017))).contains(PREY_2017);
|
||||||
|
assertThat(SpeedrunMatch.sure("Prey", Set.of(2006), List.of(PREY_2006, PREY_2017))).contains(PREY_2006);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void candidates_OneYearOffIsStillTheSameGame() {
|
void sure_NoYearAndTwoSameNames_IsEmpty() {
|
||||||
SpeedrunApiService.Game celeste = hit("celeste", "Celeste", 2018);
|
assertThat(SpeedrunMatch.sure("Prey", Set.of(), List.of(PREY_2006, PREY_2017))).isEmpty();
|
||||||
|
|
||||||
assertThat(SpeedrunMatch.candidates("Celeste", Set.of(2017), List.of(celeste))).containsExactly(celeste);
|
|
||||||
assertThat(SpeedrunMatch.candidates("Celeste", Set.of(2015), List.of(celeste))).isEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void candidates_OnlyTheSameName() {
|
void sure_NoYearAndOneSameName_IsThatGame() {
|
||||||
List<SpeedrunApiService.Game> hits = List.of(hit("snes", "Doom (SNES)", 1995), hit("doom3", "Doom 3", 2004),
|
assertThat(ids("DOOM", Set.of(), List.of(hit("snes", "Doom (SNES)", 1995), hit("doom3", "Doom 3", 2004),
|
||||||
hit("d2016", "DOOM", 2016));
|
hit("d2016", "DOOM", 2016)))).containsExactly("d2016");
|
||||||
|
|
||||||
assertThat(SpeedrunMatch.candidates("DOOM", Set.of(), hits)).extracting(SpeedrunApiService.Game::id).containsExactly("d2016");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void candidates_AtMostTwo() {
|
void sure_TwoGamesInTheYearWindow_IsEmpty() {
|
||||||
List<SpeedrunApiService.Game> hits = List.of(hit("a", "Tetris", null), hit("b", "Tetris", null), hit("c", "Tetris", null));
|
assertThat(SpeedrunMatch.sure("Tetris", Set.of(2000), List.of(hit("a", "Tetris", 2000), hit("b", "Tetris", 2001)))).isEmpty();
|
||||||
|
|
||||||
assertThat(SpeedrunMatch.candidates("Tetris", Set.of(), hits)).extracting(SpeedrunApiService.Game::id).containsExactly("a", "b");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void candidates_RefusesLinksOutsideSpeedrunCom() {
|
void sure_OneYearOff_IsTheGame() {
|
||||||
|
assertThat(ids("Celeste", Set.of(2017), List.of(hit("celeste", "Celeste", 2018)))).containsExactly("celeste");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sure_YearTwoOff_IsEmpty() {
|
||||||
|
assertThat(SpeedrunMatch.sure("Celeste", Set.of(2015), List.of(hit("celeste", "Celeste", 2018)))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sure_HitWithoutYear_OnlyWhenItIsTheOnlySameName() {
|
||||||
|
SpeedrunApiService.Game undated = hit("prey", "Prey", null);
|
||||||
|
|
||||||
|
assertThat(SpeedrunMatch.sure("Prey", Set.of(2017), List.of(undated))).contains(undated);
|
||||||
|
assertThat(SpeedrunMatch.sure("Prey", Set.of(2017), List.of(undated, hit("other", "Prey", 2006)))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sure_RomanNumeralsMatchDigits() {
|
||||||
|
assertThat(ids("Final Fantasy VII", Set.of(1997), List.of(hit("ff7", "Final Fantasy 7", 1997)))).containsExactly("ff7");
|
||||||
|
assertThat(SpeedrunMatch.sure("Mega Man X", Set.of(), List.of(hit("mm10", "Mega Man 10", 2010)))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sure_OtherNamesAreIgnored() {
|
||||||
|
assertThat(ids("Hades", Set.of(2020), List.of(hit("hades2", "Hades II", 2024), hit("hades", "Hades", 2020))))
|
||||||
|
.containsExactly("hades");
|
||||||
|
assertThat(SpeedrunMatch.sure("Hades", Set.of(2024), List.of(hit("hades2", "Hades II", 2024)))).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sure_RefusesLinksOutsideSpeedrunCom() {
|
||||||
List<SpeedrunApiService.Game> hits = List.of(
|
List<SpeedrunApiService.Game> hits = List.of(
|
||||||
new SpeedrunApiService.Game("a", "Celeste", 2018, "javascript:alert(1)"),
|
new SpeedrunApiService.Game("a", "Celeste", 2018, "javascript:alert(1)"),
|
||||||
new SpeedrunApiService.Game("b", "Celeste", 2018, "https://www.speedrun.com.evil.example/x"),
|
new SpeedrunApiService.Game("b", "Celeste", 2018, "https://www.speedrun.com.evil.example/x"),
|
||||||
new SpeedrunApiService.Game("c", "Celeste", 2018, null),
|
new SpeedrunApiService.Game("c", "Celeste", 2018, null),
|
||||||
new SpeedrunApiService.Game("d", "Celeste", 2018, SpeedrunMatch.WEBLINK_PREFIX + "x".repeat(500)));
|
new SpeedrunApiService.Game("d", "Celeste", 2018, SpeedrunMatch.WEBLINK_PREFIX + "x".repeat(500)));
|
||||||
|
|
||||||
assertThat(SpeedrunMatch.candidates("Celeste", Set.of(), hits)).isEmpty();
|
assertThat(SpeedrunMatch.sure("Celeste", Set.of(), hits)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutEditionSuffix_StripsKnownSuffixes() {
|
||||||
|
assertThat(SpeedrunMatch.withoutEditionSuffix("The Elder Scrolls V: Skyrim Special Edition"))
|
||||||
|
.contains("The Elder Scrolls V: Skyrim");
|
||||||
|
assertThat(SpeedrunMatch.withoutEditionSuffix("The Witcher 3: Wild Hunt - Game of the Year Edition"))
|
||||||
|
.contains("The Witcher 3: Wild Hunt");
|
||||||
|
assertThat(SpeedrunMatch.withoutEditionSuffix("Borderlands GOTY")).contains("Borderlands");
|
||||||
|
assertThat(SpeedrunMatch.withoutEditionSuffix("Death Stranding Director's Cut")).contains("Death Stranding");
|
||||||
|
assertThat(SpeedrunMatch.withoutEditionSuffix("DOOM Eternal Deluxe Edition")).contains("DOOM Eternal");
|
||||||
|
assertThat(SpeedrunMatch.withoutEditionSuffix("Mafia: Definitive Edition")).contains("Mafia");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutEditionSuffix_StripsTheLeadingThe() {
|
||||||
|
assertThat(SpeedrunMatch.withoutEditionSuffix("Grand Theft Auto IV: The Complete Edition")).contains("Grand Theft Auto IV");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withoutEditionSuffix_KeepsRemasteredAndPlainNames() {
|
||||||
|
assertThat(SpeedrunMatch.withoutEditionSuffix("Dark Souls Remastered")).isEmpty();
|
||||||
|
assertThat(SpeedrunMatch.withoutEditionSuffix("Prey")).isEmpty();
|
||||||
|
assertThat(SpeedrunMatch.withoutEditionSuffix("Gold Edition")).isEmpty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package core_service.modules.library.service;
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
import core_service.modules.igdb_adapter.service.IgdbApiService;
|
import core_service.modules.igdb_adapter.service.IgdbApiService;
|
||||||
|
import core_service.modules.library.domain.GameEdition;
|
||||||
import core_service.modules.library.domain.SteamGame;
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.repo.GameEditionRepository;
|
||||||
import core_service.modules.library.repo.GameSpeedrunRepository;
|
import core_service.modules.library.repo.GameSpeedrunRepository;
|
||||||
import core_service.modules.library.repo.SteamGameRepository;
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
import core_service.modules.library.repo.UserGameRepository;
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
@@ -9,7 +11,7 @@ import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
|
|||||||
import core_service.shared.test.AbstractUnitTest;
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InOrder;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.Spy;
|
import org.mockito.Spy;
|
||||||
import org.springframework.core.task.SyncTaskExecutor;
|
import org.springframework.core.task.SyncTaskExecutor;
|
||||||
@@ -29,6 +31,9 @@ import static org.mockito.ArgumentMatchers.anyString;
|
|||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.ArgumentMatchers.isNull;
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.inOrder;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -44,18 +49,23 @@ class SpeedrunServiceTest extends AbstractUnitTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private GameSpeedrunRepository gameSpeedrunRepository;
|
private GameSpeedrunRepository gameSpeedrunRepository;
|
||||||
@Mock
|
@Mock
|
||||||
|
private GameEditionRepository gameEditionRepository;
|
||||||
|
@Mock
|
||||||
private SteamGameRepository steamGameRepository;
|
private SteamGameRepository steamGameRepository;
|
||||||
@Mock
|
@Mock
|
||||||
private UserGameRepository userGameRepository;
|
private UserGameRepository userGameRepository;
|
||||||
@Spy
|
@Spy
|
||||||
private TaskExecutor syncFollowUpExecutor = new SyncTaskExecutor();
|
private TaskExecutor syncFollowUpExecutor = new SyncTaskExecutor();
|
||||||
@InjectMocks
|
@Spy
|
||||||
|
private TaskExecutor speedrunExecutor = new SyncTaskExecutor();
|
||||||
private SpeedrunService service;
|
private SpeedrunService service;
|
||||||
|
|
||||||
private static final String LINK = "https://www.speedrun.com/prey2017";
|
private static final String LINK = "https://www.speedrun.com/prey2017";
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
|
service = new SpeedrunService(speedrunApiService, igdbApiService, gameSpeedrunRepository, gameEditionRepository,
|
||||||
|
steamGameRepository, userGameRepository, syncFollowUpExecutor, speedrunExecutor);
|
||||||
when(speedrunApiService.isEnabled()).thenReturn(true);
|
when(speedrunApiService.isEnabled()).thenReturn(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +82,23 @@ class SpeedrunServiceTest extends AbstractUnitTest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void game(long appid, String name) {
|
||||||
|
when(steamGameRepository.findAllById(any())).thenReturn(List.of(new SteamGame(appid, name, null, Instant.EPOCH)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void edition(long appid, long igdbId, long sameGameId) {
|
||||||
|
GameEdition edition = mock(GameEdition.class);
|
||||||
|
lenient().when(edition.getAppid()).thenReturn(appid);
|
||||||
|
when(edition.getIgdbId()).thenReturn(igdbId);
|
||||||
|
when(edition.getSameGameId()).thenReturn(sameGameId);
|
||||||
|
when(gameEditionRepository.findAllById(any())).thenReturn(List.of(edition));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void igdbYears(long appid, Integer... years) {
|
||||||
|
when(igdbApiService.isEnabled()).thenReturn(true);
|
||||||
|
when(igdbApiService.getReleaseYears(List.of(appid))).thenReturn(Optional.of(Map.of(appid, Set.of(years))));
|
||||||
|
}
|
||||||
|
|
||||||
private static SpeedrunApiService.Game hit(String id, String name, int year) {
|
private static SpeedrunApiService.Game hit(String id, String name, int year) {
|
||||||
return new SpeedrunApiService.Game(id, name, year, "https://www.speedrun.com/" + id);
|
return new SpeedrunApiService.Game(id, name, year, "https://www.speedrun.com/" + id);
|
||||||
}
|
}
|
||||||
@@ -103,18 +130,128 @@ class SpeedrunServiceTest extends AbstractUnitTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void refresh_NoGameWithRuns_StoresAnEmptyRowAfterTwoCandidates() {
|
void refresh_SureGameWithoutRuns_StoresNoLink() {
|
||||||
names(620);
|
names(620);
|
||||||
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of(
|
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of(hit("prey", "Prey", 2006))));
|
||||||
hit("a", "Prey", 2006), hit("b", "Prey", 2006), hit("c", "Prey", 2006))));
|
when(speedrunApiService.hasVerifiedRuns("prey")).thenReturn(Optional.of(false));
|
||||||
when(speedrunApiService.hasVerifiedRuns(anyString())).thenReturn(Optional.of(false));
|
|
||||||
|
|
||||||
service.refresh(List.of(620L));
|
service.refresh(List.of(620L));
|
||||||
|
|
||||||
verify(speedrunApiService, times(2)).hasVerifiedRuns(anyString());
|
verify(speedrunApiService, times(1)).hasVerifiedRuns(anyString());
|
||||||
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
|
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refresh_SeveralSameNamesWithoutYear_StoresNoLinkWithoutAskingForRuns() {
|
||||||
|
names(620);
|
||||||
|
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of(
|
||||||
|
hit("prey", "Prey", 2006), hit("prey2017", "Prey (2017)", 2017))));
|
||||||
|
|
||||||
|
service.refresh(List.of(620L));
|
||||||
|
|
||||||
|
verify(speedrunApiService, never()).hasVerifiedRuns(anyString());
|
||||||
|
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refresh_EditionMisses_ParentNameHits() {
|
||||||
|
game(620, "The Elder Scrolls V: Skyrim Special Edition");
|
||||||
|
igdbYears(620, 2016);
|
||||||
|
edition(620, 100, 50);
|
||||||
|
when(igdbApiService.getNamesAndYears(any())).thenReturn(Optional.of(Map.of(
|
||||||
|
50L, new IgdbApiService.NameAndYear("The Elder Scrolls V: Skyrim", 2011))));
|
||||||
|
when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim Special Edition")).thenReturn(Optional.of(List.of()));
|
||||||
|
when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim")).thenReturn(Optional.of(List.of(
|
||||||
|
new SpeedrunApiService.Game("skyrim", "The Elder Scrolls V: Skyrim", 2011, "https://www.speedrun.com/skyrim"))));
|
||||||
|
when(speedrunApiService.hasVerifiedRuns("skyrim")).thenReturn(Optional.of(true));
|
||||||
|
|
||||||
|
service.refresh(List.of(620L));
|
||||||
|
|
||||||
|
verify(gameSpeedrunRepository).upsert(eq(620L), eq("skyrim"), eq("https://www.speedrun.com/skyrim"), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refresh_NoParent_TriesTheNameWithoutEditionSuffix() {
|
||||||
|
game(620, "DOOM Eternal Deluxe Edition");
|
||||||
|
when(speedrunApiService.findGames("DOOM Eternal Deluxe Edition")).thenReturn(Optional.of(List.of()));
|
||||||
|
when(speedrunApiService.findGames("DOOM Eternal")).thenReturn(Optional.of(List.of(hit("doome", "DOOM Eternal", 2020))));
|
||||||
|
when(speedrunApiService.hasVerifiedRuns("doome")).thenReturn(Optional.of(true));
|
||||||
|
|
||||||
|
service.refresh(List.of(620L));
|
||||||
|
|
||||||
|
verify(gameSpeedrunRepository).upsert(eq(620L), eq("doome"), eq("https://www.speedrun.com/doome"), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refresh_RemakeHasNoParent_DoesNotUseTheOriginal() {
|
||||||
|
game(620, "Resident Evil 2");
|
||||||
|
igdbYears(620, 2019);
|
||||||
|
edition(620, 100, 100);
|
||||||
|
when(speedrunApiService.findGames("Resident Evil 2")).thenReturn(Optional.of(List.of(hit("re2old", "Resident Evil 2", 1998))));
|
||||||
|
|
||||||
|
service.refresh(List.of(620L));
|
||||||
|
|
||||||
|
verify(speedrunApiService, times(1)).findGames(anyString());
|
||||||
|
verify(igdbApiService, never()).getNamesAndYears(any());
|
||||||
|
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refresh_RemakeWithEditionSuffix_DoesNotGetTheOriginal() {
|
||||||
|
game(620, "Mafia: Definitive Edition");
|
||||||
|
igdbYears(620, 2020);
|
||||||
|
edition(620, 100, 100);
|
||||||
|
when(speedrunApiService.findGames("Mafia: Definitive Edition")).thenReturn(Optional.of(List.of(hit("mafia", "Mafia", 2002))));
|
||||||
|
lenient().when(speedrunApiService.findGames("Mafia")).thenReturn(Optional.of(List.of(hit("mafia", "Mafia", 2002))));
|
||||||
|
lenient().when(speedrunApiService.hasVerifiedRuns("mafia")).thenReturn(Optional.of(true));
|
||||||
|
|
||||||
|
service.refresh(List.of(620L));
|
||||||
|
|
||||||
|
verify(gameSpeedrunRepository).upsert(eq(620L), isNull(), isNull(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refresh_EditionOfAMainGame_TriesTheNameWithoutEditionSuffix() {
|
||||||
|
game(620, "DOOM Eternal Deluxe Edition");
|
||||||
|
igdbYears(620, 2020);
|
||||||
|
edition(620, 100, 100);
|
||||||
|
when(speedrunApiService.findGames("DOOM Eternal Deluxe Edition")).thenReturn(Optional.of(List.of()));
|
||||||
|
lenient().when(speedrunApiService.findGames("DOOM Eternal")).thenReturn(Optional.of(List.of(hit("doome", "DOOM Eternal", 2020))));
|
||||||
|
lenient().when(speedrunApiService.hasVerifiedRuns("doome")).thenReturn(Optional.of(true));
|
||||||
|
|
||||||
|
service.refresh(List.of(620L));
|
||||||
|
|
||||||
|
verify(gameSpeedrunRepository).upsert(eq(620L), eq("doome"), eq("https://www.speedrun.com/doome"), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refresh_ParentSearchFails_StoresNothing() {
|
||||||
|
game(620, "The Elder Scrolls V: Skyrim Special Edition");
|
||||||
|
igdbYears(620, 2016);
|
||||||
|
edition(620, 100, 50);
|
||||||
|
when(igdbApiService.getNamesAndYears(any())).thenReturn(Optional.of(Map.of(
|
||||||
|
50L, new IgdbApiService.NameAndYear("The Elder Scrolls V: Skyrim", 2011))));
|
||||||
|
when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim Special Edition")).thenReturn(Optional.of(List.of()));
|
||||||
|
when(speedrunApiService.findGames("The Elder Scrolls V: Skyrim")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
service.refresh(List.of(620L));
|
||||||
|
|
||||||
|
verify(gameSpeedrunRepository, never()).upsert(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refresh_ParentNamesIgdbFailed_StoresNothing() {
|
||||||
|
game(620, "The Elder Scrolls V: Skyrim Special Edition");
|
||||||
|
igdbYears(620, 2016);
|
||||||
|
edition(620, 100, 50);
|
||||||
|
when(igdbApiService.getNamesAndYears(any())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
service.refresh(List.of(620L));
|
||||||
|
|
||||||
|
verify(speedrunApiService, never()).findGames(anyString());
|
||||||
|
verify(gameSpeedrunRepository, never()).upsert(any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void refresh_FailedCall_StoresNothing() {
|
void refresh_FailedCall_StoresNothing() {
|
||||||
names(620);
|
names(620);
|
||||||
@@ -228,6 +365,59 @@ class SpeedrunServiceTest extends AbstractUnitTest {
|
|||||||
verifyNoInteractions(gameSpeedrunRepository, steamGameRepository);
|
verifyNoInteractions(gameSpeedrunRepository, steamGameRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recheckMarked_QueuesTheMarkedGames() {
|
||||||
|
names(1, 2);
|
||||||
|
when(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).thenReturn(List.of(1L, 2L));
|
||||||
|
when(speedrunApiService.findGames("Prey")).thenReturn(Optional.of(List.of()));
|
||||||
|
|
||||||
|
service.recheckMarked();
|
||||||
|
|
||||||
|
verify(gameSpeedrunRepository).upsert(eq(1L), isNull(), isNull(), any());
|
||||||
|
verify(gameSpeedrunRepository).upsert(eq(2L), isNull(), isNull(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recheckMarked_Disabled_DoesNothing() {
|
||||||
|
when(speedrunApiService.isEnabled()).thenReturn(false);
|
||||||
|
|
||||||
|
service.recheckMarked();
|
||||||
|
|
||||||
|
verifyNoInteractions(gameSpeedrunRepository, steamGameRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recheckMarked_UserRequestQueuedAfterManyMarkedGames_IsServedNext() {
|
||||||
|
long[] marked = LongStream.rangeClosed(1, 30).toArray();
|
||||||
|
names(LongStream.concat(LongStream.of(999), LongStream.of(marked)).toArray());
|
||||||
|
when(gameSpeedrunRepository.findAppidsToRecheck(Instant.EPOCH)).thenReturn(LongStream.of(marked).boxed().toList());
|
||||||
|
when(speedrunApiService.findGames("Prey")).thenAnswer(call -> {
|
||||||
|
// The first marked game is being asked when a user asks for another one.
|
||||||
|
service.refresh(List.of(999L));
|
||||||
|
return Optional.of(List.of());
|
||||||
|
}).thenReturn(Optional.of(List.of()));
|
||||||
|
|
||||||
|
service.recheckMarked();
|
||||||
|
|
||||||
|
InOrder order = inOrder(gameSpeedrunRepository);
|
||||||
|
order.verify(gameSpeedrunRepository).upsert(eq(1L), isNull(), isNull(), any());
|
||||||
|
order.verify(gameSpeedrunRepository).upsert(eq(999L), isNull(), isNull(), any());
|
||||||
|
order.verify(gameSpeedrunRepository).upsert(eq(2L), isNull(), isNull(), any());
|
||||||
|
verify(gameSpeedrunRepository, times(31)).upsert(any(), isNull(), isNull(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void refresh_OnlyQueuesAndLeavesTheWorkToTheSpeedrunExecutor() {
|
||||||
|
TaskExecutor waiting = mock(TaskExecutor.class);
|
||||||
|
SpeedrunService queued = new SpeedrunService(speedrunApiService, igdbApiService, gameSpeedrunRepository,
|
||||||
|
gameEditionRepository, steamGameRepository, userGameRepository, syncFollowUpExecutor, waiting);
|
||||||
|
|
||||||
|
queued.refresh(List.of(620L));
|
||||||
|
|
||||||
|
verify(waiting).execute(any());
|
||||||
|
verifyNoInteractions(steamGameRepository);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void onLibrarySynced_RefreshesTheBacklogOnTheListenerThread() {
|
void onLibrarySynced_RefreshesTheBacklogOnTheListenerThread() {
|
||||||
when(userGameRepository.findBacklogAppids(7L)).thenReturn(List.of());
|
when(userGameRepository.findBacklogAppids(7L)).thenReturn(List.of());
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ public abstract class AbstractIntegrationTest {
|
|||||||
@TestBean(name = BackgroundPools.SYNC_FOLLOW_UP)
|
@TestBean(name = BackgroundPools.SYNC_FOLLOW_UP)
|
||||||
private TaskExecutor syncFollowUpExecutor;
|
private TaskExecutor syncFollowUpExecutor;
|
||||||
|
|
||||||
|
@TestBean(name = BackgroundPools.SPEEDRUN)
|
||||||
|
private TaskExecutor speedrunExecutor;
|
||||||
|
|
||||||
|
static TaskExecutor speedrunExecutor() {
|
||||||
|
return new SyncTaskExecutor();
|
||||||
|
}
|
||||||
|
|
||||||
static TaskExecutor applicationTaskExecutor() {
|
static TaskExecutor applicationTaskExecutor() {
|
||||||
return new SyncTaskExecutor();
|
return new SyncTaskExecutor();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user