mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
feat(platforms): added platform support (#67)
Now libraries can be assigned to platforms in the admin section. Games will be assigned to libraries on scanning. Resolves grimsi/gameyfin#31 Co-authored-by: shawly <shawlyde@gmail.com>
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@
|
||||
<parent>
|
||||
<artifactId>gameyfin</artifactId>
|
||||
<groupId>de.grimsi</groupId>
|
||||
<version>1.2.6-SNAPSHOT</version>
|
||||
<version>1.3.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>gameyfin-backend</artifactId>
|
||||
|
||||
@@ -6,6 +6,7 @@ import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@@ -15,4 +16,5 @@ public class AutocompleteSuggestionDto {
|
||||
private String slug;
|
||||
private String title;
|
||||
private Instant releaseDate;
|
||||
private List<String> platforms;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.grimsi.gameyfin.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class LibraryScanRequestDto {
|
||||
private String path;
|
||||
private boolean downloadImages;
|
||||
}
|
||||
@@ -77,6 +77,14 @@ public class DetectedGame {
|
||||
@ToString.Exclude
|
||||
private List<PlayerPerspective> playerPerspectives;
|
||||
|
||||
@ManyToMany(cascade = CascadeType.MERGE)
|
||||
@ToString.Exclude
|
||||
private List<Platform> platforms;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "library")
|
||||
private Library library;
|
||||
|
||||
// Technical properties
|
||||
@Column(nullable = false)
|
||||
private String path;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package de.grimsi.gameyfin.entities;
|
||||
|
||||
import lombok.*;
|
||||
import org.hibernate.Hibernate;
|
||||
|
||||
import javax.persistence.*;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Entity
|
||||
@Builder
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@AllArgsConstructor
|
||||
@RequiredArgsConstructor
|
||||
public class Library {
|
||||
|
||||
@Id
|
||||
private String path;
|
||||
|
||||
@ManyToMany(cascade = CascadeType.MERGE)
|
||||
@ToString.Exclude
|
||||
private List<Platform> platforms;
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
|
||||
Library library = (Library) o;
|
||||
return path != null && Objects.equals(path, library.path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return getClass().hashCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.grimsi.gameyfin.entities;
|
||||
|
||||
import lombok.*;
|
||||
import org.hibernate.Hibernate;
|
||||
|
||||
import javax.persistence.Column;
|
||||
import javax.persistence.Entity;
|
||||
import javax.persistence.Id;
|
||||
import java.util.Objects;
|
||||
|
||||
@Entity
|
||||
@Builder
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@AllArgsConstructor
|
||||
@RequiredArgsConstructor
|
||||
public class Platform {
|
||||
@Id
|
||||
private String slug;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
private String logoId;
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
|
||||
Platform platform = (Platform) o;
|
||||
return slug != null && Objects.equals(slug, platform.slug);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return getClass().hashCode();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ package de.grimsi.gameyfin.igdb;
|
||||
import java.util.List;
|
||||
|
||||
public class IgdbApiProperties {
|
||||
public static final String ENPOINT_GAMES_PROTOBUF = "games.pb";
|
||||
public static final String ENDPOINT_GAMES_PROTOBUF = "games.pb";
|
||||
public static final String ENDPOINT_PLATFORMS_PROTOBUF = "platforms.pb";
|
||||
|
||||
private static final List<String> GAME_QUERY_FIELDS = List.of(
|
||||
"slug", "name", "summary", "first_release_date", "rating", "aggregated_rating", "total_rating", "category",
|
||||
@@ -13,7 +14,8 @@ public class IgdbApiProperties {
|
||||
"genres.slug", "genres.name",
|
||||
"keywords.slug", "keywords.name",
|
||||
"themes.slug", "themes.name",
|
||||
"player_perspectives.slug", "player_perspectives.name"
|
||||
"player_perspectives.slug", "player_perspectives.name",
|
||||
"platforms.slug", "platforms.name", "platforms.platform_logo.image_id"
|
||||
);
|
||||
|
||||
public static final String GAME_QUERY_FIELDS_STRING = String.join(",", GAME_QUERY_FIELDS);
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
package de.grimsi.gameyfin.igdb;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Builder for fluent building of igdb api queries.
|
||||
*/
|
||||
public class IgdbApiQueryBuilder {
|
||||
private final StringBuilder stringBuilder;
|
||||
private String search;
|
||||
private String fields;
|
||||
private String limit;
|
||||
private String where;
|
||||
private String sort;
|
||||
|
||||
public IgdbApiQueryBuilder() {
|
||||
this.stringBuilder = new StringBuilder();
|
||||
this.fields = "fields *;";
|
||||
this.search = "";
|
||||
this.limit = "";
|
||||
this.where = "";
|
||||
this.sort = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Condition} that are concatenated through the `&` operator.
|
||||
* This condition produces `(condition & condition & condition)`.
|
||||
*
|
||||
* @param conditions multiple conditions
|
||||
* @return an {@link AndCondition}
|
||||
*/
|
||||
public static Condition and(Condition... conditions) {
|
||||
return new AndCondition(conditions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Condition} that are concatenated through the `|` operator.
|
||||
* This condition produces `(condition | condition | condition)`.
|
||||
*
|
||||
* @param conditions multiple conditions
|
||||
* @return an {@link OrCondition}
|
||||
*/
|
||||
public static Condition or(Condition... conditions) {
|
||||
return new OrCondition(conditions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Condition} to look for string values in a list.
|
||||
* This condition produces `field = ("val1","val2")`.
|
||||
*
|
||||
* @param field a field to search through
|
||||
* @param values the string values
|
||||
* @return an {@link InCondition}
|
||||
*/
|
||||
public static Condition in(String field, String... values) {
|
||||
return new InCondition(field, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Condition} to look for number values in a list.
|
||||
* This condition produces `field = (1,2,3)`.
|
||||
*
|
||||
* @param field a field to search through
|
||||
* @param values the number values
|
||||
* @return an {@link InCondition}
|
||||
*/
|
||||
public static Condition in(String field, Number... values) {
|
||||
return new InCondition(field, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Condition} to look for values in a collection.
|
||||
* This condition produces `field = ("val1","val2")` if a collection of strings is passed.
|
||||
* This condition produces `field = (1,2,3)` if a collection of numbers is passed.
|
||||
*
|
||||
* @param field a field to search through.
|
||||
* @param values a collection of values.
|
||||
* @return an {@link InCondition}.
|
||||
*/
|
||||
public static Condition in(String field, Collection<?> values) {
|
||||
return new InCondition(field, values.toArray(new Object[0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Condition} to filter for matching string values.
|
||||
* This condition produces `field = "value"`.
|
||||
*
|
||||
* @param field a field to search through.
|
||||
* @param value a value for comparison.
|
||||
* @return an {@link InCondition}.
|
||||
*/
|
||||
public static Condition equal(String field, String value) {
|
||||
return new EqualsCondition(field, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Condition} to filter for matching number values.
|
||||
* This condition produces `field = 123`.
|
||||
*
|
||||
* @param field a field to search through.
|
||||
* @param value a value for comparison.
|
||||
* @return an {@link InCondition}.
|
||||
*/
|
||||
public static Condition equal(String field, Number value) {
|
||||
return new EqualsCondition(field, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Condition} to filter for non-matching string values.
|
||||
* This condition produces `field != "value"`.
|
||||
*
|
||||
* @param field a field to search through.
|
||||
* @param value a value for comparison.
|
||||
* @return an {@link InCondition}.
|
||||
*/
|
||||
public static Condition not(String field, String value) {
|
||||
return new NotCondition(field, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Condition} to filter for non-matching number values.
|
||||
* This condition produces `field != 123`.
|
||||
*
|
||||
* @param field a field to search through.
|
||||
* @param value a value for comparison.
|
||||
* @return an {@link InCondition}.
|
||||
*/
|
||||
public static Condition not(String field, Number value) {
|
||||
return new NotCondition(field, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Condition} to check if a value is bigger than the given value.
|
||||
* This condition produces `field > 123`.
|
||||
*
|
||||
* @param field a field to search through.
|
||||
* @param value a value for comparison.
|
||||
* @return an {@link InCondition}.
|
||||
*/
|
||||
public static Condition greater(String field, Number value) {
|
||||
return new GreaterThanCondition(field, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Condition} to check if a value is bigger or equal to the given value.
|
||||
* This condition produces `field >= 123`.
|
||||
*
|
||||
* @param field a field to search through.
|
||||
* @param value a value for comparison.
|
||||
* @return an {@link InCondition}.
|
||||
*/
|
||||
public static Condition greaterEquals(String field, Number value) {
|
||||
return new GreaterEqualsCondition(field, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Condition} to check if a value is smaller than the given value.
|
||||
* This condition produces `field < 123`.
|
||||
*
|
||||
* @param field a field to search through.
|
||||
* @param value a value for comparison.
|
||||
* @return an {@link InCondition}.
|
||||
*/
|
||||
public static Condition lesser(String field, Number value) {
|
||||
return new LessThanCondition(field, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link Condition} to check if a value is smaller or equal to the given value.
|
||||
* This condition produces `field <= 123`.
|
||||
*
|
||||
* @param field a field to search through.
|
||||
* @param value a value for comparison.
|
||||
* @return an {@link InCondition}.
|
||||
*/
|
||||
public static Condition lesserEquals(String field, Number value) {
|
||||
return new LesserEqualsCondition(field, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the query string.
|
||||
*
|
||||
* @return an igdb compatible query
|
||||
*/
|
||||
public String build() {
|
||||
stringBuilder.append(search);
|
||||
stringBuilder.append(fields);
|
||||
stringBuilder.append(limit);
|
||||
stringBuilder.append(where);
|
||||
stringBuilder.append(sort);
|
||||
return stringBuilder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `search "xyz";` query param.
|
||||
*
|
||||
* @param searchTerm a term to search for.
|
||||
* @return the builder
|
||||
*/
|
||||
public IgdbApiQueryBuilder search(String searchTerm) {
|
||||
this.search = "search \"%s\";".formatted(searchTerm);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `fields abc,xyz;` query param.
|
||||
*
|
||||
* @param fields fields that should be returned (defaults to *).
|
||||
* @return the builder
|
||||
*/
|
||||
public IgdbApiQueryBuilder fields(String fields) {
|
||||
this.fields = "fields %s;".formatted(fields);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `limit 1234;` query param.
|
||||
*
|
||||
* @param limit how many results should be returned.
|
||||
* @return the builder
|
||||
*/
|
||||
public IgdbApiQueryBuilder limit(int limit) {
|
||||
this.limit = "limit %d;".formatted(limit);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `where xyz;` query param.
|
||||
*
|
||||
* @param condition a {@link Condition} object containing all conditions to filter the igdb db.
|
||||
* @return the builder
|
||||
*/
|
||||
public IgdbApiQueryBuilder where(Condition condition) {
|
||||
this.where = "where %s;".formatted(condition.build());
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `sort xyz asc;` query param.
|
||||
*
|
||||
* @param field a term to search for.
|
||||
* @param order the {@link SortOrder} (either ASC or DESC).
|
||||
* @return the builder
|
||||
*/
|
||||
public IgdbApiQueryBuilder sort(String field, SortOrder order) {
|
||||
this.sort = "sort %s %s;".formatted(field, order.value);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort order enum for sorting query result.
|
||||
*/
|
||||
public enum SortOrder {
|
||||
ASC("asc"), DESC("desc");
|
||||
|
||||
public final String value;
|
||||
|
||||
SortOrder(String value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract condition object.
|
||||
*/
|
||||
public abstract static class Condition {
|
||||
protected static String wrap(String conditions) {
|
||||
return "(%s)".formatted(conditions);
|
||||
}
|
||||
|
||||
public abstract String build();
|
||||
}
|
||||
|
||||
/**
|
||||
* InCondition
|
||||
*/
|
||||
public static class InCondition extends Condition {
|
||||
private static final String PATTERN = "%s = (%s)";
|
||||
private final String field;
|
||||
private final String in;
|
||||
|
||||
public InCondition(String field, Object[] values) {
|
||||
this.field = field;
|
||||
if (Arrays.stream(values).anyMatch(String.class::isInstance))
|
||||
this.in = Arrays.stream(values).map("\"%s\""::formatted).collect(Collectors.joining(","));
|
||||
else if (Arrays.stream(values).anyMatch(Number.class::isInstance))
|
||||
this.in = Arrays.stream(values).map("%d"::formatted).collect(Collectors.joining(","));
|
||||
else this.in = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String build() {
|
||||
return PATTERN.formatted(field, in);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NotCondition
|
||||
*/
|
||||
public static class NotCondition extends OperatorCondition {
|
||||
private static final String OPERATOR = "!=";
|
||||
|
||||
public NotCondition(String field, String value) {
|
||||
super(field, OPERATOR, value);
|
||||
}
|
||||
|
||||
public NotCondition(String field, Number value) {
|
||||
super(field, OPERATOR, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EqualsCondition
|
||||
*/
|
||||
public static class EqualsCondition extends OperatorCondition {
|
||||
|
||||
private static final String OPERATOR = "=";
|
||||
|
||||
public EqualsCondition(String field, String value) {
|
||||
super(field, OPERATOR, value);
|
||||
}
|
||||
|
||||
public EqualsCondition(String field, Number value) {
|
||||
super(field, OPERATOR, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GreaterThanCondition
|
||||
*/
|
||||
public static class GreaterThanCondition extends OperatorCondition {
|
||||
|
||||
private static final String OPERATOR = ">";
|
||||
|
||||
public GreaterThanCondition(String field, Number value) {
|
||||
super(field, OPERATOR, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GreaterEqualsCondition
|
||||
*/
|
||||
public static class GreaterEqualsCondition extends OperatorCondition {
|
||||
|
||||
private static final String OPERATOR = ">=";
|
||||
|
||||
public GreaterEqualsCondition(String field, Number value) {
|
||||
super(field, OPERATOR, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LessThanCondition
|
||||
*/
|
||||
public static class LessThanCondition extends OperatorCondition {
|
||||
|
||||
private static final String OPERATOR = "<";
|
||||
|
||||
public LessThanCondition(String field, Number value) {
|
||||
super(field, OPERATOR, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LesserEqualsCondition
|
||||
*/
|
||||
public static class LesserEqualsCondition extends OperatorCondition {
|
||||
|
||||
private static final String OPERATOR = "<=";
|
||||
|
||||
public LesserEqualsCondition(String field, Number value) {
|
||||
super(field, OPERATOR, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OperatorCondition for inheritance
|
||||
*/
|
||||
public static class OperatorCondition extends Condition {
|
||||
private static final String PATTERN = "%s %s %s";
|
||||
private static final String ESCAPED_STRING = "\"%s\"";
|
||||
private static final String DIGITS = "%s";
|
||||
|
||||
private final String field;
|
||||
private final String value;
|
||||
private final String operator;
|
||||
|
||||
public OperatorCondition(String field, String operator, String value) {
|
||||
this.field = field;
|
||||
this.operator = operator;
|
||||
this.value = value != null ? ESCAPED_STRING.formatted(value) : null;
|
||||
}
|
||||
|
||||
public OperatorCondition(String field, String operator, Number value) {
|
||||
this.field = field;
|
||||
this.operator = operator;
|
||||
this.value = value != null ? DIGITS.formatted(value) : null;
|
||||
}
|
||||
|
||||
public OperatorCondition() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String build() {
|
||||
return PATTERN.formatted(field, operator, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AndCondition
|
||||
*/
|
||||
public static class AndCondition extends Condition {
|
||||
private static final String AND = " & ";
|
||||
|
||||
private final Condition[] conditions;
|
||||
|
||||
public AndCondition(Condition[] conditions) {
|
||||
this.conditions = conditions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String build() {
|
||||
return wrap(Arrays.stream(conditions).map(Condition::build).collect(Collectors.joining(AND)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OrCondition
|
||||
*/
|
||||
public static class OrCondition extends Condition {
|
||||
private static final String OR = " | ";
|
||||
|
||||
private final Condition[] conditions;
|
||||
|
||||
public OrCondition(Condition[] conditions) {
|
||||
this.conditions = conditions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String build() {
|
||||
return wrap(Arrays.stream(conditions).map(Condition::build).collect(Collectors.joining(OR)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ package de.grimsi.gameyfin.igdb;
|
||||
import com.igdb.proto.Igdb;
|
||||
import de.grimsi.gameyfin.config.WebClientConfig;
|
||||
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
|
||||
import de.grimsi.gameyfin.entities.Platform;
|
||||
import de.grimsi.gameyfin.igdb.IgdbApiQueryBuilder.Condition;
|
||||
import de.grimsi.gameyfin.igdb.dto.TwitchOAuthTokenDto;
|
||||
import de.grimsi.gameyfin.mapper.GameMapper;
|
||||
import de.grimsi.gameyfin.mapper.PlatformMapper;
|
||||
import io.github.resilience4j.reactor.bulkhead.operator.BulkheadOperator;
|
||||
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -17,35 +20,34 @@ import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.net.URI;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static de.grimsi.gameyfin.igdb.IgdbApiProperties.GAME_QUERY_FIELDS_STRING;
|
||||
import static de.grimsi.gameyfin.igdb.IgdbApiQueryBuilder.*;
|
||||
import static org.apache.commons.lang3.ObjectUtils.isNotEmpty;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@Service
|
||||
public class IgdbWrapper {
|
||||
@Value("${gameyfin.igdb.api.client-id}")
|
||||
private String clientId;
|
||||
|
||||
@Value("${gameyfin.igdb.api.client-secret}")
|
||||
private String clientSecret;
|
||||
|
||||
@Value("${gameyfin.igdb.config.preferred-platforms:6}")
|
||||
private String preferredPlatforms;
|
||||
|
||||
@Value("${gameyfin.igdb.api.endpoints.base}")
|
||||
private String igdbApiBaseUrl;
|
||||
|
||||
@Value("${gameyfin.igdb.api.endpoints.auth}")
|
||||
private String twitchAuthUrl;
|
||||
|
||||
private final WebClient.Builder webclientBuilder;
|
||||
private final WebClientConfig webClientConfig;
|
||||
private final GameMapper gameMapper;
|
||||
|
||||
@Value("${gameyfin.igdb.api.client-id}")
|
||||
private String clientId;
|
||||
@Value("${gameyfin.igdb.api.client-secret}")
|
||||
private String clientSecret;
|
||||
@Value("${gameyfin.igdb.config.preferred-platforms:6}")
|
||||
private List<Integer> preferredPlatforms;
|
||||
@Value("${gameyfin.igdb.api.endpoints.base}")
|
||||
private String igdbApiBaseUrl;
|
||||
@Value("${gameyfin.igdb.api.endpoints.auth}")
|
||||
private String twitchAuthUrl;
|
||||
private WebClient twitchApiClient;
|
||||
|
||||
private WebClient igdbApiClient;
|
||||
@@ -80,9 +82,13 @@ public class IgdbWrapper {
|
||||
}
|
||||
|
||||
public Optional<Igdb.Game> getGameById(Long id) {
|
||||
IgdbApiQueryBuilder queryBuilder = new IgdbApiQueryBuilder();
|
||||
Igdb.GameResult gameResult = queryIgdbApi(
|
||||
IgdbApiProperties.ENPOINT_GAMES_PROTOBUF,
|
||||
"fields %s; where id = %d; limit 1;".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, id),
|
||||
IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF,
|
||||
queryBuilder.fields(GAME_QUERY_FIELDS_STRING)
|
||||
.where(equal("id", id))
|
||||
.limit(1)
|
||||
.build(),
|
||||
Igdb.GameResult.class
|
||||
);
|
||||
|
||||
@@ -92,9 +98,13 @@ public class IgdbWrapper {
|
||||
}
|
||||
|
||||
public Optional<Igdb.Game> getGameBySlug(String slug) {
|
||||
IgdbApiQueryBuilder queryBuilder = new IgdbApiQueryBuilder();
|
||||
Igdb.GameResult gameResult = queryIgdbApi(
|
||||
IgdbApiProperties.ENPOINT_GAMES_PROTOBUF,
|
||||
"fields %s; where slug = \"%s\"; limit 1;".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, slug),
|
||||
IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF,
|
||||
queryBuilder.fields(GAME_QUERY_FIELDS_STRING)
|
||||
.where(equal("slug", slug))
|
||||
.limit(1)
|
||||
.build(),
|
||||
Igdb.GameResult.class
|
||||
);
|
||||
|
||||
@@ -104,9 +114,14 @@ public class IgdbWrapper {
|
||||
}
|
||||
|
||||
public List<AutocompleteSuggestionDto> findPossibleMatchingTitles(String searchTerm, int limit) {
|
||||
IgdbApiQueryBuilder queryBuilder = new IgdbApiQueryBuilder();
|
||||
Igdb.GameResult gameResult = queryIgdbApi(
|
||||
IgdbApiProperties.ENPOINT_GAMES_PROTOBUF,
|
||||
"search \"%s\"; fields slug,name,first_release_date; where platforms = (%s); limit %d;".formatted(searchTerm, preferredPlatforms, limit),
|
||||
IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF,
|
||||
queryBuilder.search(searchTerm)
|
||||
.fields("slug,name,first_release_date,platforms.name")
|
||||
.where(in("platforms", preferredPlatforms))
|
||||
.limit(limit)
|
||||
.build(),
|
||||
Igdb.GameResult.class
|
||||
);
|
||||
|
||||
@@ -116,10 +131,21 @@ public class IgdbWrapper {
|
||||
}
|
||||
|
||||
public Optional<Igdb.Game> searchForGameByTitle(String searchTerm) {
|
||||
return searchForGameByTitle(searchTerm, List.of());
|
||||
}
|
||||
|
||||
public Optional<Igdb.Game> searchForGameByTitle(String searchTerm, Collection<String> platformSlugs) {
|
||||
IgdbApiQueryBuilder queryBuilder = new IgdbApiQueryBuilder();
|
||||
Condition platforms = isNotEmpty(platformSlugs) ?
|
||||
and(in("platforms", preferredPlatforms), in("platforms.slug", platformSlugs)) :
|
||||
in("platforms", preferredPlatforms);
|
||||
|
||||
Igdb.GameResult gameResult = queryIgdbApi(
|
||||
IgdbApiProperties.ENPOINT_GAMES_PROTOBUF,
|
||||
"search \"%s\"; fields %s; where platforms = (%s);"
|
||||
.formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING, preferredPlatforms),
|
||||
IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF,
|
||||
queryBuilder.search(searchTerm)
|
||||
.fields(GAME_QUERY_FIELDS_STRING)
|
||||
.where(platforms)
|
||||
.build(),
|
||||
Igdb.GameResult.class
|
||||
);
|
||||
|
||||
@@ -134,7 +160,7 @@ public class IgdbWrapper {
|
||||
if (hasBrackets.find()) {
|
||||
String searchTermWithoutBrackets = searchTerm.split(brackets.pattern())[0].trim();
|
||||
log.warn("Trying again with search term '{}'", searchTermWithoutBrackets);
|
||||
return searchForGameByTitle(searchTermWithoutBrackets);
|
||||
return searchForGameByTitle(searchTermWithoutBrackets, platformSlugs);
|
||||
}
|
||||
|
||||
return Optional.empty();
|
||||
@@ -186,4 +212,35 @@ public class IgdbWrapper {
|
||||
.transformDeferred(RateLimiterOperator.of(webClientConfig.getIgdbRateLimiter()))
|
||||
.block();
|
||||
}
|
||||
|
||||
public List<Platform> findPlatforms(String searchTerm, int limit) {
|
||||
IgdbApiQueryBuilder queryBuilder = new IgdbApiQueryBuilder();
|
||||
Igdb.PlatformResult platformResult = queryIgdbApi(
|
||||
IgdbApiProperties.ENDPOINT_PLATFORMS_PROTOBUF,
|
||||
queryBuilder.search(searchTerm)
|
||||
.fields("slug,name")
|
||||
.limit(limit)
|
||||
.build(),
|
||||
Igdb.PlatformResult.class
|
||||
);
|
||||
|
||||
if (platformResult == null) return Collections.emptyList();
|
||||
|
||||
return platformResult.getPlatformsList().stream().map(PlatformMapper::toPlatform).toList();
|
||||
}
|
||||
|
||||
public Platform getPlatformBySlug(String slug) {
|
||||
IgdbApiQueryBuilder queryBuilder = new IgdbApiQueryBuilder();
|
||||
Igdb.PlatformResult platformResult = queryIgdbApi(
|
||||
IgdbApiProperties.ENDPOINT_PLATFORMS_PROTOBUF,
|
||||
queryBuilder.fields("slug,name,platform_logo")
|
||||
.where(equal("slug", slug))
|
||||
.build(),
|
||||
Igdb.PlatformResult.class
|
||||
);
|
||||
|
||||
if (platformResult == null) return null;
|
||||
|
||||
return platformResult.getPlatformsList().stream().map(PlatformMapper::toPlatform).findFirst().orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,25 +4,23 @@ import com.igdb.proto.Igdb;
|
||||
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
|
||||
import de.grimsi.gameyfin.dto.GameOverviewDto;
|
||||
import de.grimsi.gameyfin.entities.DetectedGame;
|
||||
import de.grimsi.gameyfin.entities.Library;
|
||||
import de.grimsi.gameyfin.service.FilesystemService;
|
||||
import de.grimsi.gameyfin.service.LibraryService;
|
||||
import de.grimsi.gameyfin.util.ProtobufUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.io.FileUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StopWatch;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.FileSystems;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static java.util.stream.Collectors.toList;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@@ -31,7 +29,7 @@ public class GameMapper {
|
||||
|
||||
private final FilesystemService filesystemService;
|
||||
|
||||
public DetectedGame toDetectedGame(Igdb.Game g, Path path) {
|
||||
public DetectedGame toDetectedGame(Igdb.Game g, Path path, Library library) {
|
||||
List<Igdb.MultiplayerMode> multiplayerModes = g.getMultiplayerModesList();
|
||||
List<String> screenshotIds = g.getScreenshotsList().stream().map(Igdb.Screenshot::getImageId).toList();
|
||||
List<String> videoIds = g.getVideosList().stream().map(Igdb.GameVideo::getVideoId).toList();
|
||||
@@ -57,7 +55,9 @@ public class GameMapper {
|
||||
.keywords(KeywordMapper.toKeywords(g.getKeywordsList()))
|
||||
.themes(ThemeMapper.toThemes(g.getThemesList()))
|
||||
.playerPerspectives(PlayerPerspectiveMapper.toPlayerPerspectives(g.getPlayerPerspectivesList()))
|
||||
.platforms(PlatformMapper.toPlatforms(g.getPlatformsList()))
|
||||
.path(path.toString())
|
||||
.library(library)
|
||||
.diskSize(calculateDiskSize(g, path))
|
||||
.addedToLibrary(Instant.now())
|
||||
.build();
|
||||
@@ -76,13 +76,14 @@ public class GameMapper {
|
||||
.slug(game.getSlug())
|
||||
.title(game.getName())
|
||||
.releaseDate(ProtobufUtil.toInstant(game.getFirstReleaseDate()))
|
||||
.platforms(game.getPlatformsList().stream().map(Igdb.Platform::getName).toList())
|
||||
.build();
|
||||
}
|
||||
|
||||
private String getCoverId(Igdb.Game g) {
|
||||
String coverId = g.getCover().getImageId();
|
||||
|
||||
if(StringUtils.hasText(coverId)) return coverId;
|
||||
if (StringUtils.hasText(coverId)) return coverId;
|
||||
|
||||
return "nocover";
|
||||
}
|
||||
@@ -113,7 +114,7 @@ public class GameMapper {
|
||||
|
||||
try {
|
||||
fileSize = filesystemService.getSizeOnDisk(path);
|
||||
} catch(IOException e) {
|
||||
} catch (IOException e) {
|
||||
log.error("Error while calculating disk size for game '{}'", g.getName());
|
||||
fileSize = -1L;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.grimsi.gameyfin.mapper;
|
||||
|
||||
import com.igdb.proto.Igdb;
|
||||
import de.grimsi.gameyfin.entities.Platform;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class PlatformMapper {
|
||||
|
||||
public static Platform toPlatform(Igdb.Platform c) {
|
||||
return Platform.builder()
|
||||
.slug(c.getSlug())
|
||||
.name(c.getName())
|
||||
.logoId(c.getPlatformLogo().getImageId())
|
||||
.build();
|
||||
}
|
||||
|
||||
public static List<Platform> toPlatforms(List<Igdb.Platform> c) {
|
||||
return c.stream().map(PlatformMapper::toPlatform).toList();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
package de.grimsi.gameyfin.repositories;
|
||||
|
||||
import de.grimsi.gameyfin.entities.DetectedGame;
|
||||
import de.grimsi.gameyfin.entities.UnmappableFile;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import static org.apache.commons.lang3.StringUtils.isBlank;
|
||||
|
||||
public interface DetectedGameRepository extends JpaRepository<DetectedGame, String> {
|
||||
|
||||
@@ -16,10 +21,17 @@ public interface DetectedGameRepository extends JpaRepository<DetectedGame, Stri
|
||||
|
||||
Optional<DetectedGame> findByPath(String path);
|
||||
|
||||
List<DetectedGame> getAllByPathNotIn(Collection<String> paths);
|
||||
List<DetectedGame> findByPathStartsWithAndLibraryIsNull(String path);
|
||||
|
||||
default List<DetectedGame> getAllByPathNotIn(List<Path> paths) {
|
||||
List<DetectedGame> getAllByPathNotIn(Collection<String> paths);
|
||||
List<DetectedGame> getAllByPathNotInAndPathStartsWith(Collection<String> paths, String libraryPath);
|
||||
|
||||
default List<DetectedGame> getAllByPathNotInAndPathStartsWith(List<Path> paths, String libraryPath) {
|
||||
List<String> pathStrings = paths.stream().map(Path::toString).toList();
|
||||
return getAllByPathNotIn(pathStrings);
|
||||
// get games that are not in the paths list but are starting with libraryPath if libraryPath is not empty
|
||||
return isBlank(libraryPath) ? getAllByPathNotIn(pathStrings) : getAllByPathNotInAndPathStartsWith(pathStrings, libraryPath);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.grimsi.gameyfin.repositories;
|
||||
|
||||
import de.grimsi.gameyfin.entities.Library;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface LibraryRepository extends JpaRepository<Library, String> {
|
||||
|
||||
boolean existsByPathIgnoreCase(String path);
|
||||
|
||||
Optional<Library> findByPath(String path);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.grimsi.gameyfin.repositories;
|
||||
|
||||
import de.grimsi.gameyfin.entities.Platform;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PlatformRepository extends JpaRepository<Platform, String> {
|
||||
|
||||
boolean existsBySlug(String slug);
|
||||
|
||||
Optional<Platform> findBySlug(String slug);
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import static org.apache.commons.lang3.StringUtils.isBlank;
|
||||
|
||||
public interface UnmappableFileRepository extends JpaRepository<UnmappableFile, Long> {
|
||||
|
||||
@@ -14,10 +17,14 @@ public interface UnmappableFileRepository extends JpaRepository<UnmappableFile,
|
||||
|
||||
List<UnmappableFile> getAllByPathNotIn(Collection<String> paths);
|
||||
|
||||
List<UnmappableFile> getAllByPathNotInAndPathStartsWith(Collection<String> paths, String libraryPath);
|
||||
|
||||
|
||||
Optional<UnmappableFile> findByPath(String path);
|
||||
|
||||
default List<UnmappableFile> getAllByPathNotIn(List<Path> paths) {
|
||||
default List<UnmappableFile> getAllByPathNotInAndPathStartsWith(List<Path> paths, String libraryPath) {
|
||||
List<String> pathStrings = paths.stream().map(Path::toString).toList();
|
||||
return getAllByPathNotIn(pathStrings);
|
||||
// get unmapped files that are not in the paths list but are starting with libraryPath if libraryPath is not empty
|
||||
return isBlank(libraryPath) ? getAllByPathNotIn(pathStrings) : getAllByPathNotInAndPathStartsWith(pathStrings, libraryPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package de.grimsi.gameyfin.rest;
|
||||
|
||||
import de.grimsi.gameyfin.dto.ImageDownloadResultDto;
|
||||
import de.grimsi.gameyfin.dto.LibraryScanResult;
|
||||
import de.grimsi.gameyfin.dto.LibraryScanResultDto;
|
||||
import de.grimsi.gameyfin.service.DownloadService;
|
||||
import de.grimsi.gameyfin.dto.*;
|
||||
import de.grimsi.gameyfin.entities.Library;
|
||||
import de.grimsi.gameyfin.service.ImageService;
|
||||
import de.grimsi.gameyfin.service.LibraryService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -11,14 +9,13 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.util.StopWatch;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.apache.commons.lang3.StringUtils.isNotBlank;
|
||||
|
||||
/**
|
||||
* This controller handles functionality of the library.
|
||||
*/
|
||||
@@ -32,20 +29,23 @@ public class LibraryController {
|
||||
private final LibraryService libraryService;
|
||||
private final ImageService imageService;
|
||||
|
||||
@GetMapping(value = "/scan", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public LibraryScanResultDto scanLibrary(@RequestParam(value = "download_images", defaultValue = "true") boolean downloadImages) {
|
||||
@PostMapping(value = "/scan", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public LibraryScanResultDto scanLibraries(@RequestBody LibraryScanRequestDto libraryScanRequest) {
|
||||
StopWatch stopWatch = new StopWatch();
|
||||
stopWatch.start();
|
||||
|
||||
LibraryScanResultDto lscDto = new LibraryScanResultDto();
|
||||
|
||||
LibraryScanResult lsc = libraryService.scanGameLibrary();
|
||||
lscDto.setNewGames(lsc.getNewGames());
|
||||
lscDto.setDeletedGames(lsc.getDeletedGames());
|
||||
lscDto.setNewUnmappableFiles(lsc.getNewUnmappableFiles());
|
||||
lscDto.setTotalGames(lsc.getTotalGames());
|
||||
String path = libraryScanRequest.getPath();
|
||||
List<Library> libraries = isNotBlank(path) ? List.of(libraryService.getLibrary(path)) : libraryService.getLibraries();
|
||||
List<LibraryScanResult> libraryScanResults = libraries.stream().map(libraryService::scanGameLibrary).toList();
|
||||
|
||||
if(downloadImages) {
|
||||
lscDto.setNewGames(libraryScanResults.stream().map(LibraryScanResult::getNewGames).reduce(0, Integer::sum));
|
||||
lscDto.setDeletedGames(libraryScanResults.stream().map(LibraryScanResult::getDeletedGames).reduce(0, Integer::sum));
|
||||
lscDto.setNewUnmappableFiles(libraryScanResults.stream().map(LibraryScanResult::getNewUnmappableFiles).reduce(0, Integer::sum));
|
||||
lscDto.setTotalGames(libraryScanResults.stream().map(LibraryScanResult::getTotalGames).reduce(0, Integer::sum));
|
||||
|
||||
if (libraryScanRequest.isDownloadImages()) {
|
||||
ImageDownloadResultDto idrDto = downloadImages();
|
||||
|
||||
lscDto.setCoverDownloads(idrDto.getCoverDownloads());
|
||||
|
||||
@@ -4,9 +4,9 @@ package de.grimsi.gameyfin.rest;
|
||||
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
|
||||
import de.grimsi.gameyfin.dto.PathToSlugDto;
|
||||
import de.grimsi.gameyfin.entities.DetectedGame;
|
||||
import de.grimsi.gameyfin.entities.Library;
|
||||
import de.grimsi.gameyfin.entities.Platform;
|
||||
import de.grimsi.gameyfin.entities.UnmappableFile;
|
||||
import de.grimsi.gameyfin.repositories.DetectedGameRepository;
|
||||
import de.grimsi.gameyfin.service.DownloadService;
|
||||
import de.grimsi.gameyfin.service.GameService;
|
||||
import de.grimsi.gameyfin.service.ImageService;
|
||||
import de.grimsi.gameyfin.service.LibraryService;
|
||||
@@ -62,4 +62,20 @@ public class LibraryManagementController {
|
||||
public List<AutocompleteSuggestionDto> getAutocompleteSuggestions(@RequestParam String searchTerm, @RequestParam(required = false, defaultValue = "10") int limit) {
|
||||
return libraryService.getAutocompleteSuggestions(searchTerm, limit);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/platforms", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public List<Platform> getPlatforms(@RequestParam String searchTerm, @RequestParam(required = false, defaultValue = "10") int limit) {
|
||||
return libraryService.getPlatforms(searchTerm, limit);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/libraries", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public List<Library> getLibraries() {
|
||||
return libraryService.getOrCreateLibraries();
|
||||
}
|
||||
|
||||
@PostMapping(value = "/map-library", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public Library mapPathToPlatform(@RequestBody PathToSlugDto pathToSlugDto) {
|
||||
return libraryService.mapPlatformsToLibrary(pathToSlugDto.getPath(), pathToSlugDto.getSlug());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ package de.grimsi.gameyfin.service;
|
||||
import com.igdb.proto.Igdb;
|
||||
import de.grimsi.gameyfin.dto.GameOverviewDto;
|
||||
import de.grimsi.gameyfin.entities.DetectedGame;
|
||||
import de.grimsi.gameyfin.entities.Library;
|
||||
import de.grimsi.gameyfin.entities.UnmappableFile;
|
||||
import de.grimsi.gameyfin.igdb.IgdbWrapper;
|
||||
import de.grimsi.gameyfin.mapper.GameMapper;
|
||||
import de.grimsi.gameyfin.repositories.DetectedGameRepository;
|
||||
import de.grimsi.gameyfin.repositories.LibraryRepository;
|
||||
import de.grimsi.gameyfin.repositories.UnmappableFileRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -14,6 +16,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -30,6 +33,8 @@ public class GameService {
|
||||
private final DetectedGameRepository detectedGameRepository;
|
||||
private final UnmappableFileRepository unmappableFileRepository;
|
||||
|
||||
private final LibraryRepository libraryRepository;
|
||||
|
||||
public List<DetectedGame> getAllDetectedGames() {
|
||||
return detectedGameRepository.findAll();
|
||||
}
|
||||
@@ -104,7 +109,11 @@ public class GameService {
|
||||
Igdb.Game igdbGame = igdbWrapper.getGameBySlug(slug)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(slug)));
|
||||
|
||||
DetectedGame game = gameMapper.toDetectedGame(igdbGame, Path.of(unmappableFile.getPath()));
|
||||
Path path = Path.of(unmappableFile.getPath());
|
||||
// parent file should equal to the library
|
||||
File libraryPath = path.toFile().getParentFile();
|
||||
Library library = libraryRepository.findByPath(libraryPath.toString()).orElse(null);
|
||||
DetectedGame game = gameMapper.toDetectedGame(igdbGame, path, library);
|
||||
game.setConfirmedMatch(true);
|
||||
|
||||
game = detectedGameRepository.save(game);
|
||||
@@ -118,13 +127,13 @@ public class GameService {
|
||||
Igdb.Game igdbGame = igdbWrapper.getGameBySlug(slug)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Game with slug '%s' does not exist on IGDB.".formatted(slug)));
|
||||
|
||||
DetectedGame game = gameMapper.toDetectedGame(igdbGame, Path.of(existingGame.getPath()));
|
||||
Path path = Path.of(existingGame.getPath());
|
||||
// parent file should equal to the library
|
||||
File libraryPath = path.toFile().getParentFile();
|
||||
Library library = libraryRepository.findByPath(libraryPath.toString()).orElse(null);
|
||||
DetectedGame game = gameMapper.toDetectedGame(igdbGame, path, library);
|
||||
game.setConfirmedMatch(true);
|
||||
|
||||
game = detectedGameRepository.save(game);
|
||||
|
||||
detectedGameRepository.delete(existingGame);
|
||||
|
||||
return game;
|
||||
return detectedGameRepository.save(game);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,29 +4,38 @@ import com.igdb.proto.Igdb;
|
||||
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
|
||||
import de.grimsi.gameyfin.dto.LibraryScanResult;
|
||||
import de.grimsi.gameyfin.entities.DetectedGame;
|
||||
import de.grimsi.gameyfin.entities.Library;
|
||||
import de.grimsi.gameyfin.entities.Platform;
|
||||
import de.grimsi.gameyfin.entities.UnmappableFile;
|
||||
import de.grimsi.gameyfin.igdb.IgdbWrapper;
|
||||
import de.grimsi.gameyfin.mapper.GameMapper;
|
||||
import de.grimsi.gameyfin.repositories.DetectedGameRepository;
|
||||
import de.grimsi.gameyfin.repositories.LibraryRepository;
|
||||
import de.grimsi.gameyfin.repositories.PlatformRepository;
|
||||
import de.grimsi.gameyfin.repositories.UnmappableFileRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StopWatch;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static de.grimsi.gameyfin.util.FilenameUtil.getFilenameWithoutExtension;
|
||||
import static de.grimsi.gameyfin.util.FilenameUtil.hasGameArchiveExtension;
|
||||
import static java.util.stream.Collectors.toList;
|
||||
import static java.util.stream.Collectors.toSet;
|
||||
import static org.apache.commons.lang3.StringUtils.isBlank;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -40,11 +49,17 @@ public class LibraryService {
|
||||
private final GameMapper gameMapper;
|
||||
private final DetectedGameRepository detectedGameRepository;
|
||||
private final UnmappableFileRepository unmappableFileRepository;
|
||||
private final LibraryRepository libraryRepository;
|
||||
private final PlatformRepository platformRepository;
|
||||
|
||||
public List<Path> getGameFiles() {
|
||||
return getGameFiles(null);
|
||||
}
|
||||
|
||||
public List<Path> getGameFiles(String path) {
|
||||
List<Path> gamefiles = new ArrayList<>();
|
||||
|
||||
libraryFolders.stream().map(Path::of).forEach(
|
||||
libraryFolders.stream().map(Path::of).filter(allPathsOrSpecific(path)).forEach(
|
||||
folder -> {
|
||||
try (Stream<Path> stream = Files.list(folder)) {
|
||||
// return all sub-folders (non-recursive) and files that have an extension that indicates that they are a downloadable file
|
||||
@@ -60,11 +75,11 @@ public class LibraryService {
|
||||
})
|
||||
// filter out all empty directories
|
||||
.filter(p -> {
|
||||
if(!Files.isDirectory(p)) return true;
|
||||
if (!Files.isDirectory(p)) return true;
|
||||
|
||||
try(DirectoryStream<Path> s = Files.newDirectoryStream(p)) {
|
||||
try (DirectoryStream<Path> s = Files.newDirectoryStream(p)) {
|
||||
return s.iterator().hasNext();
|
||||
} catch(IOException e) {
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Error while checking if folder '%s' is empty.".formatted(p), e);
|
||||
}
|
||||
})
|
||||
@@ -81,7 +96,11 @@ public class LibraryService {
|
||||
return gamefiles;
|
||||
}
|
||||
|
||||
public LibraryScanResult scanGameLibrary() {
|
||||
private static Predicate<Path> allPathsOrSpecific(String path) {
|
||||
return p -> isBlank(path) || p.equals(Path.of(path));
|
||||
}
|
||||
|
||||
public LibraryScanResult scanGameLibrary(Library library) {
|
||||
StopWatch stopWatch = new StopWatch();
|
||||
|
||||
log.info("Starting scan...");
|
||||
@@ -89,16 +108,17 @@ public class LibraryService {
|
||||
|
||||
AtomicInteger newUnmappedFilesCounter = new AtomicInteger();
|
||||
|
||||
List<Path> gameFiles = getGameFiles();
|
||||
String libraryPath = library.getPath();
|
||||
List<Path> gameFiles = getGameFiles(libraryPath);
|
||||
|
||||
// Check if any games that are in the library have been removed from the file system
|
||||
// This would include renamed files, but they will be re-detected by the next step
|
||||
List<DetectedGame> deletedGames = detectedGameRepository.getAllByPathNotIn(gameFiles);
|
||||
List<DetectedGame> deletedGames = detectedGameRepository.getAllByPathNotInAndPathStartsWith(gameFiles, libraryPath);
|
||||
detectedGameRepository.deleteAll(deletedGames);
|
||||
deletedGames.forEach(g -> log.info("Game '{}' has been moved or deleted.", g.getPath()));
|
||||
|
||||
// Now check if there are any unmapped files that have been removed from the file system
|
||||
List<UnmappableFile> deletedUnmappableFiles = unmappableFileRepository.getAllByPathNotIn(gameFiles);
|
||||
List<UnmappableFile> deletedUnmappableFiles = unmappableFileRepository.getAllByPathNotInAndPathStartsWith(gameFiles, libraryPath);
|
||||
unmappableFileRepository.deleteAll(deletedUnmappableFiles);
|
||||
deletedUnmappableFiles.forEach(g -> log.info("Unmapped file '{}' has been moved or deleted.", g.getPath()));
|
||||
|
||||
@@ -109,14 +129,21 @@ public class LibraryService {
|
||||
.peek(p -> log.info("Found new potential game: {}", p))
|
||||
.toList();
|
||||
|
||||
// Check if library has assigned platforms, so we can search for matching games by specific platforms to get a more accurate match
|
||||
Set<String> platformsFilter = libraryRepository.findByPath(libraryPath).map(Library::getPlatforms)
|
||||
.map(platforms -> platforms.stream()
|
||||
.map(Platform::getSlug)
|
||||
.collect(Collectors.toSet()))
|
||||
.orElse(Set.of());
|
||||
|
||||
// For each new game, load the info from IGDB
|
||||
// If a game is not found on IGDB, add it to the list of unmapped files, so we won't query the API later on for the same path
|
||||
// If a game is not found on IGDB, blacklist the path, so we won't query the API later for the same path
|
||||
List<DetectedGame> newDetectedGames = gameFiles.parallelStream()
|
||||
.map(p -> {
|
||||
Optional<Igdb.Game> optionalGame = igdbWrapper.searchForGameByTitle(getFilenameWithoutExtension(p));
|
||||
Optional<Igdb.Game> optionalGame = igdbWrapper.searchForGameByTitle(getFilenameWithoutExtension(p), platformsFilter);
|
||||
|
||||
if(optionalGame.isPresent() && detectedGameRepository.existsBySlug(optionalGame.get().getSlug())) {
|
||||
if (optionalGame.isPresent() && detectedGameRepository.existsBySlug(optionalGame.get().getSlug())) {
|
||||
log.warn("Game with slug '{}' already exists in database", optionalGame.get().getSlug());
|
||||
optionalGame = Optional.empty();
|
||||
}
|
||||
@@ -131,14 +158,21 @@ public class LibraryService {
|
||||
.filter(Optional::isPresent)
|
||||
.map(Optional::get)
|
||||
.peek(e -> log.info("Mapped file '{}' to game '{}' (slug: {})", e.getKey(), e.getValue().getName(), e.getValue().getSlug()))
|
||||
.map(e -> gameMapper.toDetectedGame(e.getValue(), e.getKey()))
|
||||
.collect(Collectors.toList());
|
||||
.map(e -> gameMapper.toDetectedGame(e.getValue(), e.getKey(), library))
|
||||
.toList();
|
||||
|
||||
List<DetectedGame> duplicateGames = getDuplicates(newDetectedGames);
|
||||
newUnmappedFilesCounter.getAndAdd(duplicateGames.size());
|
||||
newDetectedGames.removeAll(duplicateGames);
|
||||
|
||||
newDetectedGames = detectedGameRepository.saveAll(newDetectedGames);
|
||||
try {
|
||||
newDetectedGames = detectedGameRepository.saveAll(newDetectedGames);
|
||||
} catch (Exception e) {
|
||||
log.error("Could not save {} detected games!", newDetectedGames.size());
|
||||
List<UnmappableFile> unmappableFiles = newDetectedGames.stream()
|
||||
.map(game -> new UnmappableFile(game.getPath())).toList();
|
||||
unmappableFileRepository.saveAll(unmappableFiles);
|
||||
}
|
||||
|
||||
stopWatch.stop();
|
||||
|
||||
@@ -158,11 +192,60 @@ public class LibraryService {
|
||||
}
|
||||
|
||||
private List<DetectedGame> getDuplicates(List<DetectedGame> gamesToFilter) {
|
||||
return gamesToFilter.stream().filter(g -> Collections.frequency(gamesToFilter, g) >1)
|
||||
return gamesToFilter.stream().filter(g -> Collections.frequency(gamesToFilter, g) > 1)
|
||||
.peek(d -> {
|
||||
log.warn("Found duplicate for game '{}' under path '{}'. Mapping must be done manually.", d.getTitle(), d.getPath());
|
||||
unmappableFileRepository.save(new UnmappableFile(d.getPath()));
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<Library> getLibraries() {
|
||||
return libraryRepository.findAll();
|
||||
}
|
||||
|
||||
public Library getLibrary(String path) {
|
||||
return libraryRepository.findByPath(path).orElse(null);
|
||||
}
|
||||
|
||||
public List<Library> getOrCreateLibraries() {
|
||||
libraryFolders.stream().map(Path::of)
|
||||
.filter(path -> path.toFile().isDirectory()) // check if path is a valid directory
|
||||
.filter(path -> !libraryRepository.existsByPathIgnoreCase(path.toString()))
|
||||
.forEach(path -> {
|
||||
// save new paths as library without platforms
|
||||
Library library = new Library(path.toString(), List.of());
|
||||
libraryRepository.save(library);
|
||||
});
|
||||
|
||||
List<Library> libraries = libraryRepository.findAll();
|
||||
libraries.forEach(library -> {
|
||||
// remap existing games to this library as well
|
||||
List<DetectedGame> gamesWithoutLibraryAssignment =
|
||||
detectedGameRepository.findByPathStartsWithAndLibraryIsNull(library.getPath());
|
||||
gamesWithoutLibraryAssignment.forEach(game -> game.setLibrary(library));
|
||||
detectedGameRepository.saveAll(gamesWithoutLibraryAssignment);
|
||||
});
|
||||
return libraries;
|
||||
}
|
||||
|
||||
public List<Platform> getPlatforms(String searchTerm, int limit) {
|
||||
return igdbWrapper.findPlatforms(searchTerm, limit);
|
||||
}
|
||||
|
||||
public Library mapPlatformsToLibrary(String path, String slugs) {
|
||||
Library library = libraryRepository.findByPath(path)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Could not find library for path %s".formatted(path)));
|
||||
|
||||
Set<String> platformSlugs = Arrays.stream(slugs.split(",")).collect(toSet());
|
||||
List<Platform> platforms = platformSlugs.stream()
|
||||
.map(slug -> platformRepository.findBySlug(slug).
|
||||
orElseGet(() -> igdbWrapper.getPlatformBySlug(slug)))
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
library.setPlatforms(platforms);
|
||||
libraryRepository.save(library);
|
||||
|
||||
return library;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Add platforms
|
||||
|
||||
-- Platforms
|
||||
CREATE TABLE platform
|
||||
(
|
||||
slug VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255),
|
||||
logo_id VARCHAR(255),
|
||||
PRIMARY KEY (slug)
|
||||
);
|
||||
|
||||
-- Game <-> Platforms
|
||||
CREATE TABLE detected_game_platforms
|
||||
(
|
||||
detected_game_slug VARCHAR(255) NOT NULL,
|
||||
platforms_slug VARCHAR(255) NOT NULL
|
||||
);
|
||||
ALTER TABLE detected_game_platforms
|
||||
ADD CONSTRAINT platforms_platform_slug FOREIGN KEY (platforms_slug) REFERENCES platform;
|
||||
ALTER TABLE detected_game_platforms
|
||||
ADD CONSTRAINT platforms_detected_game_slug FOREIGN KEY (detected_game_slug) REFERENCES detected_game;
|
||||
|
||||
-- Add libraries
|
||||
|
||||
-- Libraries
|
||||
CREATE TABLE library
|
||||
(
|
||||
path VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (path)
|
||||
);
|
||||
|
||||
-- Library <-> Platforms
|
||||
CREATE TABLE library_platforms
|
||||
(
|
||||
library_path VARCHAR(255) NOT NULL,
|
||||
platforms_slug VARCHAR(255) NOT NULL
|
||||
);
|
||||
ALTER TABLE library_platforms
|
||||
ADD CONSTRAINT libraries_platform_slug FOREIGN KEY (platforms_slug) REFERENCES platform;
|
||||
ALTER TABLE library_platforms
|
||||
ADD CONSTRAINT libraries_library_path FOREIGN KEY (library_path) REFERENCES library;
|
||||
|
||||
-- Library <-> Game
|
||||
ALTER TABLE detected_game
|
||||
ADD library VARCHAR(255);
|
||||
@@ -26,6 +26,7 @@ import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Level;
|
||||
@@ -59,7 +60,7 @@ class IgdbWrapperTest {
|
||||
ReflectionTestUtils.setField(target, "clientSecret", "client_secret_value");
|
||||
ReflectionTestUtils.setField(target, "igdbApiBaseUrl", "http://localhost:%s".formatted(igdbApiMock.getPort()));
|
||||
ReflectionTestUtils.setField(target, "twitchAuthUrl", "http://localhost:%s/oauth2/token".formatted(twitchApiMock.getPort()));
|
||||
ReflectionTestUtils.setField(target, "preferredPlatforms", "preferred_platforms");
|
||||
ReflectionTestUtils.setField(target, "preferredPlatforms", List.of(6));
|
||||
|
||||
when(webClientConfigMock.getIgdbConcurrencyLimiter()).thenReturn(Bulkhead.of("test_bulkhead", BulkheadConfig.ofDefaults()));
|
||||
when(webClientConfigMock.getIgdbRateLimiter()).thenReturn(RateLimiter.of("test_ratelimiter", RateLimiterConfig.ofDefaults()));
|
||||
@@ -114,9 +115,9 @@ class IgdbWrapperTest {
|
||||
|
||||
RecordedRequest r = igdbApiMock.takeRequest();
|
||||
assertThat(r.getRequestUrl()).isNotNull();
|
||||
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF));
|
||||
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
|
||||
|
||||
String expectedQuery = "fields %s; where id = %d; limit 1;".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, gameId);
|
||||
String expectedQuery = "fields %s;limit 1;where id = %d;".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, gameId);
|
||||
|
||||
assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery);
|
||||
}
|
||||
@@ -147,9 +148,9 @@ class IgdbWrapperTest {
|
||||
|
||||
RecordedRequest r = igdbApiMock.takeRequest();
|
||||
assertThat(r.getRequestUrl()).isNotNull();
|
||||
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF));
|
||||
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
|
||||
|
||||
String expectedQuery = "fields %s; where slug = \"%s\"; limit 1;".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, gameSlug);
|
||||
String expectedQuery = "fields %s;limit 1;where slug = \"%s\";".formatted(IgdbApiProperties.GAME_QUERY_FIELDS_STRING, gameSlug);
|
||||
|
||||
assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery);
|
||||
}
|
||||
@@ -176,9 +177,9 @@ class IgdbWrapperTest {
|
||||
|
||||
RecordedRequest r = igdbApiMock.takeRequest();
|
||||
assertThat(r.getRequestUrl()).isNotNull();
|
||||
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF));
|
||||
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
|
||||
|
||||
String expectedQuery = "search \"%s\"; fields slug,name,first_release_date; where platforms = (preferred_platforms); limit %d;".formatted(gameTitle, gameResult.getGamesCount());
|
||||
String expectedQuery = "search \"%s\";fields slug,name,first_release_date,platforms.name;limit %d;where platforms = (6);".formatted(gameTitle, gameResult.getGamesCount());
|
||||
|
||||
assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery);
|
||||
}
|
||||
@@ -209,9 +210,9 @@ class IgdbWrapperTest {
|
||||
|
||||
RecordedRequest r = igdbApiMock.takeRequest();
|
||||
assertThat(r.getRequestUrl()).isNotNull();
|
||||
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF));
|
||||
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
|
||||
|
||||
String expectedQuery = "search \"%s\"; fields %s; where platforms = (preferred_platforms);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING);
|
||||
String expectedQuery = "search \"%s\";fields %s;where platforms = (6);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING);
|
||||
|
||||
assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery);
|
||||
}
|
||||
@@ -242,9 +243,9 @@ class IgdbWrapperTest {
|
||||
|
||||
RecordedRequest r = igdbApiMock.takeRequest();
|
||||
assertThat(r.getRequestUrl()).isNotNull();
|
||||
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF));
|
||||
assertThat(r.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
|
||||
|
||||
String expectedQuery = "search \"%s\"; fields %s; where platforms = (preferred_platforms);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING);
|
||||
String expectedQuery = "search \"%s\";fields %s;where platforms = (6);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING);
|
||||
|
||||
assertThat(r.getBody().readUtf8()).isEqualTo(expectedQuery);
|
||||
}
|
||||
@@ -281,18 +282,18 @@ class IgdbWrapperTest {
|
||||
// First query (should contain brackets)
|
||||
RecordedRequest r1 = igdbApiMock.takeRequest();
|
||||
assertThat(r1.getRequestUrl()).isNotNull();
|
||||
assertThat(r1.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF));
|
||||
assertThat(r1.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
|
||||
|
||||
String r1_expectedQuery = "search \"%s\"; fields %s; where platforms = (preferred_platforms);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING);
|
||||
String r1_expectedQuery = "search \"%s\";fields %s;where platforms = (6);".formatted(searchTerm, IgdbApiProperties.GAME_QUERY_FIELDS_STRING);
|
||||
|
||||
assertThat(r1.getBody().readUtf8()).isEqualTo(r1_expectedQuery);
|
||||
|
||||
// Second query (should not contain brackets)
|
||||
RecordedRequest r2 = igdbApiMock.takeRequest();
|
||||
assertThat(r2.getRequestUrl()).isNotNull();
|
||||
assertThat(r2.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENPOINT_GAMES_PROTOBUF));
|
||||
assertThat(r2.getRequestUrl().encodedPath()).isEqualTo("/%s".formatted(IgdbApiProperties.ENDPOINT_GAMES_PROTOBUF));
|
||||
|
||||
String r2_expectedQuery = "search \"%s\"; fields %s; where platforms = (preferred_platforms);".formatted(gameResult.getGames(0).getName(), IgdbApiProperties.GAME_QUERY_FIELDS_STRING);
|
||||
String r2_expectedQuery = "search \"%s\";fields %s;where platforms = (6);".formatted(gameResult.getGames(0).getName(), IgdbApiProperties.GAME_QUERY_FIELDS_STRING);
|
||||
|
||||
assertThat(r2.getBody().readUtf8()).isEqualTo(r2_expectedQuery);
|
||||
}
|
||||
|
||||
@@ -16,4 +16,15 @@ gameyfin.sources=<comma-seperated list of root folders of your game libraries>
|
||||
|
||||
# Your twitch client-id and client-secret
|
||||
gameyfin.igdb.api.client-id=<your twitch client-id here>
|
||||
gameyfin.igdb.api.client-secret=<your twitch client-secret here>
|
||||
gameyfin.igdb.api.client-secret=<your twitch client-secret here>
|
||||
|
||||
# Preferred platforms
|
||||
# PC (Microsoft Windows) (id=6), Dreamcast (id=23), Game Boy (id=33), Game Boy Advance (id=24), Game Boy Color (id=22),
|
||||
# Linux (id=3), Mac (id=14), New Nintendo 3DS (id=137), Nintendo 3DS (id=37), Nintendo 64 (id=4), Nintendo DS (id=20),
|
||||
# Nintendo DSi (id=159), Nintendo Entertainment System (id=18), Nintendo GameCube (id=21), Nintendo PlayStation (id=131),
|
||||
# Nintendo Switch (id=130), PlayStation (id=7), PlayStation 2 (id=8), PlayStation 3 (id=9), PlayStation 4 (id=48),
|
||||
# PlayStation Portable (id=38), PlayStation Vita (id=46), PlayStation VR (id=165), Sega Game Gear (id=35),
|
||||
# Sega Master System/Mark III (id=64), Sega Mega Drive/Genesis (id=29), Sega Saturn (id=32), SteamVR (id=163),
|
||||
# Super Famicom (id=58), Super Nintendo Entertainment System (id=19), Virtual Console (Nintendo) (id=47), Wii (id=5),
|
||||
# Wii U (id=41), Xbox (id=11), Xbox 360 (id=12)
|
||||
gameyfin.igdb.config.preferred-platforms=6,23,33,24,22,3,14,137,37,4,20,159,18,21,130,7,8,9,48,38,46,165,35,64,29,32,58,19,47,5,41,11,12
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.2.5-SNAPSHOT",
|
||||
"version": "1.3.0-SNAPSHOT",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.2.5-SNAPSHOT",
|
||||
"version": "1.3.0-SNAPSHOT",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^14.0.0",
|
||||
"@angular/cdk": "^14.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.2.5-SNAPSHOT",
|
||||
"version": "1.3.0-SNAPSHOT",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<parent>
|
||||
<artifactId>gameyfin</artifactId>
|
||||
<groupId>de.grimsi</groupId>
|
||||
<version>1.2.6-SNAPSHOT</version>
|
||||
<version>1.3.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {Observable} from "rxjs";
|
||||
import {LibraryScanResultDto} from "../models/dtos/LibraryScanResultDto";
|
||||
import {ImageDownloadResultDto} from "../models/dtos/ImageDownloadResultDto";
|
||||
import {LibraryScanRequestDto} from "../models/dtos/LibraryScanRequestDto";
|
||||
|
||||
export interface LibraryApi {
|
||||
scanLibrary(): Observable<LibraryScanResultDto>;
|
||||
scanLibrary(mappedLibrary: LibraryScanRequestDto): Observable<LibraryScanResultDto>;
|
||||
|
||||
downloadImages(): Observable<ImageDownloadResultDto>;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {Observable} from "rxjs";
|
||||
import {DetectedGameDto} from "../models/dtos/DetectedGameDto";
|
||||
import {UnmappedFileDto} from "../models/dtos/UnmappedFileDto";
|
||||
import {AutocompleteSuggestionDto} from "../models/dtos/AutocompleteSuggestionDto";
|
||||
import {LibraryDto} from "../models/dtos/LibraryDto";
|
||||
|
||||
export interface LibraryManagementApi {
|
||||
mapGame(pathToSlugDto: PathToSlugDto): Observable<DetectedGameDto>;
|
||||
@@ -11,4 +12,5 @@ export interface LibraryManagementApi {
|
||||
deleteGame(slug: string): Observable<Response>;
|
||||
deleteUnmappedFile(id: number): Observable<Response>;
|
||||
getAutocompleteSuggestions(searchTerm: string, limit: number): Observable<AutocompleteSuggestionDto[]>;
|
||||
getLibraries(): Observable<LibraryDto[]>;
|
||||
}
|
||||
|
||||
@@ -38,10 +38,12 @@ import {MatChipsModule} from "@angular/material/chips";
|
||||
import { LibraryManagementComponent } from './components/library-management/library-management.component';
|
||||
import {MatTooltipModule} from "@angular/material/tooltip";
|
||||
import {MapGameDialogComponent} from "./components/map-game-dialog/map-game-dialog.component";
|
||||
import {MapLibraryDialogComponent} from "./components/map-library-dialog/map-library-dialog.component";
|
||||
import {MatSlideToggleModule} from "@angular/material/slide-toggle";
|
||||
import {MatCheckboxModule} from "@angular/material/checkbox";
|
||||
import {A11yModule} from "@angular/cdk/a11y";
|
||||
import { MappedGamesTableComponent } from './components/mapped-games-table/mapped-games-table.component';
|
||||
import { MappedLibrariesTableComponent } from './components/mapped-libraries-table/mapped-libraries-table.component';
|
||||
import {MatTableFilterModule} from "mat-table-filter";
|
||||
import { UnmappedFilesTableComponent } from './components/unmapped-files-table/unmapped-files-table.component';
|
||||
import {MatDividerModule} from "@angular/material/divider";
|
||||
@@ -68,7 +70,9 @@ import { ProgressBarColorDirective } from './directives/progress-bar-color.direc
|
||||
GameVideoComponent,
|
||||
LibraryManagementComponent,
|
||||
MapGameDialogComponent,
|
||||
MapLibraryDialogComponent,
|
||||
MappedGamesTableComponent,
|
||||
MappedLibrariesTableComponent,
|
||||
UnmappedFilesTableComponent,
|
||||
NgModelChangeDebouncedDirective,
|
||||
ProgressBarColorDirective,
|
||||
|
||||
@@ -58,6 +58,14 @@
|
||||
(click)="goToLibraryWithFilter('themes', theme.slug)">{{theme.name}}</mat-chip>
|
||||
</mat-chip-list>
|
||||
</div>
|
||||
|
||||
<div *ngIf="game.platforms !== undefined && game.platforms.length > 0">
|
||||
<h2>Platforms</h2>
|
||||
<mat-chip-list>
|
||||
<mat-chip *ngFor="let platform of game.platforms" [disabled]="game.library == undefined || !hasPlatform(game.library, platform)"
|
||||
(click)="goToLibraryWithFilter('platforms', platform.slug)">{{platform.name}}</mat-chip>
|
||||
</mat-chip-list>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div fxFlex fxLayout="row" fxLayoutGap="16px">
|
||||
|
||||
@@ -3,6 +3,8 @@ import {ActivatedRoute, Params, Router} from "@angular/router";
|
||||
import {DetectedGameDto} from "../../models/dtos/DetectedGameDto";
|
||||
import {GamesService} from "../../services/games.service";
|
||||
import {CompanyDto} from "../../models/dtos/CompanyDto";
|
||||
import {LibraryDto} from "../../models/dtos/LibraryDto";
|
||||
import {PlatformDto} from "../../models/dtos/PlatformDto";
|
||||
|
||||
@Component({
|
||||
selector: 'app-game-detail-view',
|
||||
@@ -110,4 +112,8 @@ export class GameDetailViewComponent {
|
||||
return Math.floor(containerWidth / elementWidth);
|
||||
}
|
||||
|
||||
hasPlatform(library: LibraryDto, platform: PlatformDto) {
|
||||
return library.platforms.some((libPlatform) => libPlatform.slug == platform.slug)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {Router} from "@angular/router";
|
||||
import {GamesService} from "../../services/games.service";
|
||||
import {ThemingService} from "../../services/theming.service";
|
||||
import {Location} from '@angular/common';
|
||||
import {LibraryScanRequestDto} from "../../models/dtos/LibraryScanRequestDto";
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
@@ -25,7 +26,9 @@ export class HeaderComponent {
|
||||
}
|
||||
|
||||
scanLibrary(): void {
|
||||
this.libraryService.scanLibrary().subscribe({
|
||||
let request = new LibraryScanRequestDto();
|
||||
request.downloadImages = true;
|
||||
this.libraryService.scanLibrary(request).subscribe({
|
||||
next: result => {
|
||||
// Refresh the current page "angular style"
|
||||
this.router.navigate([this.router.url]).then(() => {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<div fxFlexFill>
|
||||
<div *ngIf="loggedIn && (this.unmappedFiles.length > 0 || this.mappedGames.length > 0)" fxFlex fxLayoutAlign="center start">
|
||||
<div *ngIf="loggedIn && (this.mappedLibraries.length > 0)" fxFlex fxLayoutAlign="center start">
|
||||
<mat-tab-group>
|
||||
<mat-tab label="Library mappings">
|
||||
<mapped-libraries-table [mappedLibraries]="mappedLibraries"></mapped-libraries-table>
|
||||
</mat-tab>
|
||||
<mat-tab label="Game mappings">
|
||||
<mapped-games-table [mappedGames]="mappedGames"></mapped-games-table>
|
||||
</mat-tab>
|
||||
@@ -19,7 +22,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="loggedIn && this.unmappedFiles.length === 0 && this.mappedGames.length === 0" fxFlex fxLayout="column" fxLayoutAlign="center center">
|
||||
<div *ngIf="loggedIn && this.mappedLibraries.length === 0" fxFlex fxLayout="column" fxLayoutAlign="center center">
|
||||
<div class="library-management-hint" fxLayout="column" fxLayoutAlign="start end">
|
||||
<mat-icon fontSet="material-icons-outlined">north_east</mat-icon>
|
||||
<p>Use the library management to scan your file system for games</p>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {GamesService} from "../../services/games.service";
|
||||
import {LibraryManagementService} from "../../services/library-management.service";
|
||||
import {DetectedGameDto} from "../../models/dtos/DetectedGameDto";
|
||||
import {UnmappedFileDto} from "../../models/dtos/UnmappedFileDto";
|
||||
import {LibraryDto} from "../../models/dtos/LibraryDto";
|
||||
|
||||
@Component({
|
||||
selector: 'app-library-management',
|
||||
@@ -14,6 +15,7 @@ export class LibraryManagementComponent implements OnInit {
|
||||
|
||||
mappedGames: DetectedGameDto[] = [];
|
||||
unmappedFiles: UnmappedFileDto[] = [];
|
||||
mappedLibraries: LibraryDto[] = [];
|
||||
|
||||
constructor(private gamesService: GamesService,
|
||||
private libraryManagementService: LibraryManagementService) {
|
||||
@@ -25,6 +27,10 @@ export class LibraryManagementComponent implements OnInit {
|
||||
this.unmappedFiles = uf;
|
||||
this.loggedIn = true;
|
||||
});
|
||||
this.libraryManagementService.getLibraries().subscribe(libraries => {
|
||||
this.mappedLibraries = libraries;
|
||||
this.loggedIn = true;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -114,6 +114,18 @@
|
||||
color="primary">{{playerPerspective.name}}</mat-checkbox>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
|
||||
<mat-expansion-panel *ngIf="availablePlatforms.length > 0" [expanded]="activePlatformFilters.length > 0">
|
||||
<mat-expansion-panel-header>
|
||||
<h3 class="filter-category-title">Platforms</h3>
|
||||
</mat-expansion-panel-header>
|
||||
|
||||
<div fxLayout="column">
|
||||
<mat-checkbox *ngFor="let platform of availablePlatforms" (change)="togglePlatformFilter(platform.slug)"
|
||||
[checked]="activePlatformFilters.includes(platform.slug)"
|
||||
color="primary">{{platform.name}}</mat-checkbox>
|
||||
</div>
|
||||
</mat-expansion-panel>
|
||||
</div>
|
||||
|
||||
<div fxFlex="0 1 1"><!--SPACER--></div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {ThemeDto} from "../../models/dtos/ThemeDto";
|
||||
import {firstValueFrom, forkJoin, Observable} from "rxjs";
|
||||
import {SortDirection} from "@angular/material/sort";
|
||||
import {PlayerPerspectiveDto} from "../../models/dtos/PlayerPerspectiveDto";
|
||||
import {PlatformDto} from "../../models/dtos/PlatformDto";
|
||||
import {ActivatedRoute, ActivatedRouteSnapshot, Params, Router} from "@angular/router";
|
||||
import {Location} from "@angular/common";
|
||||
|
||||
@@ -52,11 +53,13 @@ export class LibraryOverviewComponent implements AfterContentInit {
|
||||
activeThemeFilters: string[] = [];
|
||||
activeGenreFilters: string[] = [];
|
||||
activePlayerPerspectiveFilters: string[] = [];
|
||||
activePlatformFilters: string[] = [];
|
||||
|
||||
games: DetectedGameDto[] = [];
|
||||
availableGenres: GenreDto[] = [];
|
||||
availableThemes: ThemeDto[] = [];
|
||||
availablePlayerPerspectives: PlayerPerspectiveDto[] = [];
|
||||
availablePlatforms: PlatformDto[] = [];
|
||||
|
||||
loading: boolean = true;
|
||||
gameLibraryIsEmpty: boolean = false;
|
||||
@@ -82,11 +85,13 @@ export class LibraryOverviewComponent implements AfterContentInit {
|
||||
let genreObservable: Observable<ThemeDto[]> = this.gameServerService.getAvailableGenres();
|
||||
let themeObservable: Observable<GenreDto[]> = this.gameServerService.getAvailableThemes();
|
||||
let playerPerspectiveObservable: Observable<PlayerPerspectiveDto[]> = this.gameServerService.getAvailablePlayerPerspectives();
|
||||
let platformObservable: Observable<PlatformDto[]> = this.gameServerService.getAvailablePlatforms();
|
||||
|
||||
forkJoin([genreObservable, themeObservable, playerPerspectiveObservable]).subscribe(result => {
|
||||
forkJoin([genreObservable, themeObservable, playerPerspectiveObservable, platformObservable]).subscribe(result => {
|
||||
this.availableGenres = result[0];
|
||||
this.availableThemes = result[1];
|
||||
this.availablePlayerPerspectives = result[2];
|
||||
this.availablePlatforms = result[3];
|
||||
|
||||
this.previousStateParams = this.route.snapshot.queryParams;
|
||||
if (this.previousStateParams['search'] !== undefined) this.searchTerm = this.previousStateParams['search'];
|
||||
@@ -95,6 +100,7 @@ export class LibraryOverviewComponent implements AfterContentInit {
|
||||
if (this.previousStateParams['genres'] !== undefined) this.activeGenreFilters = this.matchSelectedFilters(this.availableGenres, this.previousStateParams['genres']);
|
||||
if (this.previousStateParams['themes'] !== undefined) this.activeThemeFilters = this.matchSelectedFilters(this.availableThemes, this.previousStateParams['themes']);
|
||||
if (this.previousStateParams['playerPerspectives'] !== undefined) this.activePlayerPerspectiveFilters = this.matchSelectedFilters(this.availablePlayerPerspectives, this.previousStateParams['playerPerspectives']);
|
||||
if (this.previousStateParams['platforms'] !== undefined) this.activePlatformFilters = this.matchSelectedFilters(this.availablePlatforms, this.previousStateParams['platforms']);
|
||||
|
||||
this.refreshLibraryView().then(() => this.loading = false);
|
||||
});
|
||||
@@ -134,6 +140,11 @@ export class LibraryOverviewComponent implements AfterContentInit {
|
||||
games = games.filter(game => this.activePlayerPerspectiveFilters.every(activePlayerPerspectiveFilter => game.playerPerspectives?.map(g => g.slug).includes(activePlayerPerspectiveFilter)));
|
||||
}
|
||||
|
||||
if (this.activePlatformFilters.length > 0) {
|
||||
games = games.filter(game => this.activePlatformFilters.some(activePlatformFilter =>
|
||||
game?.library?.platforms?.map(g => g.slug).includes(activePlatformFilter) && game?.platforms?.map(g => g.slug).includes(activePlatformFilter)));
|
||||
}
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
@@ -197,6 +208,21 @@ export class LibraryOverviewComponent implements AfterContentInit {
|
||||
this.refreshLibraryView();
|
||||
}
|
||||
|
||||
togglePlatformFilter(slug: string): void {
|
||||
if (this.activePlatformFilters.includes(slug)) {
|
||||
|
||||
const index = this.activePlatformFilters.indexOf(slug, 0);
|
||||
if (index > -1) {
|
||||
this.activePlatformFilters.splice(index, 1);
|
||||
}
|
||||
|
||||
} else {
|
||||
this.activePlatformFilters.push(slug);
|
||||
}
|
||||
|
||||
this.refreshLibraryView();
|
||||
}
|
||||
|
||||
private saveStateToRoute(): void {
|
||||
let newStateParams: Params = {};
|
||||
|
||||
@@ -206,6 +232,7 @@ export class LibraryOverviewComponent implements AfterContentInit {
|
||||
if (this.activeGenreFilters.length > 0) newStateParams['genres'] = this.activeGenreFilters.join(',');
|
||||
if (this.activeThemeFilters.length > 0) newStateParams['themes'] = this.activeThemeFilters.join(',');
|
||||
if (this.activePlayerPerspectiveFilters.length > 0) newStateParams['playerPerspectives'] = this.activePlayerPerspectiveFilters.join(',');
|
||||
if (this.activePlatformFilters.length > 0) newStateParams['platforms'] = this.activePlatformFilters.join(',');
|
||||
|
||||
// only update the route if it changed
|
||||
if (JSON.stringify(this.previousStateParams) !== JSON.stringify(newStateParams)) {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<mat-autocomplete #igdbSlugAutocomplete="matAutocomplete">
|
||||
<mat-option *ngFor="let suggestion of autocompleteSuggestions" [value]="suggestion.slug">
|
||||
{{suggestion.title}} ({{getFullYearFromTimestamp(suggestion.releaseDate)}})
|
||||
{{suggestion.title}} ({{getFullYearFromTimestamp(suggestion.releaseDate)}}) - {{suggestion.platforms.join(', ')}}
|
||||
</mat-option>
|
||||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<h3 mat-dialog-title>Map path to IGDB platform</h3>
|
||||
<mat-dialog-content>
|
||||
<form fxLayout="column" fxLayoutAlign="space-evenly stretch">
|
||||
|
||||
<p>Path: {{path}}</p>
|
||||
<p><a href="https://www.igdb.com/platforms" target="_blank">Available platforms</a></p>
|
||||
|
||||
<mat-form-field>
|
||||
<div fxLayout="row">
|
||||
<input type="text" placeholder="IGDB Platform Slugs" matInput [matAutocomplete]="igdbPlatformSlugsAutocomplete" [(ngModel)]="slugs" (ngModelChangeDebounced)="loadSuggestions()" [ngModelOptions]="{standalone: true}">
|
||||
<mat-spinner *ngIf="suggestionsLoading" [diameter]="16"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<mat-autocomplete #igdbPlatformSlugsAutocomplete="matAutocomplete">
|
||||
<mat-option *ngFor="let suggestion of autocompletePlatformSuggestions" [value]="previousSlugs+suggestion.slug">
|
||||
{{suggestion.name}}
|
||||
</mat-option>
|
||||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-raised-button [mat-dialog-close]="false" color="accent" [disabled]="submitLoading">Cancel</button>
|
||||
<button mat-raised-button (click)="submit()" [disabled]="slugs.length < 1 || submitLoading" color="primary">
|
||||
<span *ngIf="!submitLoading">OK</span>
|
||||
<div *ngIf="submitLoading" fxLayout="column" fxLayoutAlign="center center" style="height: 36px;">
|
||||
<mat-spinner [diameter]="24"></mat-spinner>
|
||||
</div>
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MapLibraryDialogComponent } from './map-library-dialog.component';
|
||||
|
||||
describe('MapLibraryDialogComponent', () => {
|
||||
let component: MapLibraryDialogComponent;
|
||||
let fixture: ComponentFixture<MapLibraryDialogComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ MapLibraryDialogComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MapLibraryDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import {Component, Inject, OnInit} from '@angular/core';
|
||||
import {LibraryManagementService} from "../../services/library-management.service";
|
||||
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
|
||||
import {PathToSlugDto} from "../../models/dtos/PathToSlugDto";
|
||||
import {DialogService} from "../../services/dialog.service";
|
||||
import {ApiErrorResponse} from "../../models/dtos/ApiErrorResponse";
|
||||
import {PlatformDto} from "../../models/dtos/PlatformDto";
|
||||
|
||||
@Component({
|
||||
selector: 'app-map-library-dialog',
|
||||
templateUrl: './map-library-dialog.component.html',
|
||||
styleUrls: ['./map-library-dialog.component.scss']
|
||||
})
|
||||
export class MapLibraryDialogComponent implements OnInit {
|
||||
|
||||
path: string;
|
||||
slugs: string;
|
||||
previousSlugs: string;
|
||||
|
||||
autocompletePlatformSuggestions: PlatformDto[] = [];
|
||||
|
||||
submitLoading: boolean = false;
|
||||
suggestionsLoading: boolean = false;
|
||||
|
||||
constructor(private libraryManagementService: LibraryManagementService,
|
||||
private dialogService: DialogService,
|
||||
public dialogRef: MatDialogRef<MapLibraryDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) data: any) {
|
||||
this.path = data.path;
|
||||
this.slugs = data.slugs ?? '';
|
||||
this.previousSlugs = data.previousSlugs ?? '';
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.loadInitialSuggestions();
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
this.submitLoading = true;
|
||||
this.libraryManagementService.mapLibrary(new PathToSlugDto(Array.isArray(this.slugs) ? this.slugs.join(',') : this.slugs, this.path)).subscribe({
|
||||
next: () => this.dialogRef.close(true),
|
||||
error: (error: ApiErrorResponse) => {
|
||||
this.dialogRef.close(false);
|
||||
this.dialogService.showErrorDialog(error.error.message);
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
loadInitialSuggestions(): void {
|
||||
this.suggestionsLoading = true;
|
||||
|
||||
// Extract the last path element (folder name / file name)
|
||||
let extractedPlatformFromPath: string = this.path.match(/([^\\/]*)[\\/]*$/)![1];
|
||||
// Match it until the first special characters
|
||||
extractedPlatformFromPath = extractedPlatformFromPath.match(/^[a-zA-Z0-9:\- ]+/)![0];
|
||||
|
||||
if(extractedPlatformFromPath == null) {
|
||||
this.suggestionsLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.libraryManagementService.getPlatforms(extractedPlatformFromPath, 10).subscribe({
|
||||
next: suggestions => {
|
||||
this.autocompletePlatformSuggestions = suggestions;
|
||||
this.suggestionsLoading = false;
|
||||
},
|
||||
error: () => this.suggestionsLoading = false
|
||||
})
|
||||
}
|
||||
|
||||
loadSuggestions(): void {
|
||||
this.suggestionsLoading = true;
|
||||
let searchTerm = '';
|
||||
if (this.slugs.length > 0) {
|
||||
let slugArray = this.slugs.split(',');
|
||||
// pop off the search term after the last comma
|
||||
searchTerm = slugArray.pop() ?? '';
|
||||
// if we already had slugs in our input field we need to add them back again
|
||||
this.previousSlugs = (slugArray.length > 0 ? slugArray.join(',') + ',' : '');
|
||||
}
|
||||
this.libraryManagementService.getPlatforms(searchTerm, 50).subscribe({
|
||||
next: suggestions => {
|
||||
this.autocompletePlatformSuggestions = suggestions;
|
||||
this.suggestionsLoading = false;
|
||||
},
|
||||
error: () => this.suggestionsLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<div class="mat-elevation-z8">
|
||||
<table mat-table matSort matTableFilter [dataSource]="dataSource" [exampleEntity]="filter" [debounceTime]="0">
|
||||
<!-- Path column -->
|
||||
<ng-container matColumnDef="path">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Path</th>
|
||||
<td mat-cell *matCellDef="let element"> {{element.path}} </td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Platform column -->
|
||||
<ng-container matColumnDef="platforms">
|
||||
<th mat-header-cell *matHeaderCellDef mat-sort-header>Platforms</th>
|
||||
<td mat-cell *matCellDef="let element"><span *ngFor="let item of element.platforms; let isLast=last">{{item.name}}{{isLast ? '' : ', '}}</span></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>
|
||||
<button mat-icon-button (click)="refreshMappedLibrariesList()">
|
||||
<mat-icon matTooltip="Refresh library list" matTooltipPosition="below">refresh</mat-icon>
|
||||
</button>
|
||||
</th>
|
||||
|
||||
<!-- Action column -->
|
||||
<td mat-cell *matCellDef="let element">
|
||||
<button mat-icon-button (click)="openLibraryMappingDialog(element)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button matTooltip="Scan this library" (click)="scanLibrary(element)">
|
||||
<mat-icon>youtube_searched_for</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator
|
||||
[length]="dataSource?.data?.length"
|
||||
[pageIndex]="0"
|
||||
[pageSize]="15"
|
||||
[pageSizeOptions]="[10, 15, 25, 50]">
|
||||
</mat-paginator>
|
||||
</div>
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
table {
|
||||
width: 50vw;
|
||||
min-width: 750px;
|
||||
}
|
||||
|
||||
.mat-column-actions {
|
||||
width: 20%;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
|
||||
import { MappedLibrariesTableComponent } from './mapped-libraries-table.component';
|
||||
|
||||
describe('MappedLibrariesTableComponent', () => {
|
||||
let component: MappedLibrariesTableComponent;
|
||||
let fixture: ComponentFixture<MappedLibrariesTableComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ MappedLibrariesTableComponent ],
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatTableModule,
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(MappedLibrariesTableComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should compile', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import {AfterViewInit, Component, Input, OnChanges, SimpleChanges, ViewChild} from '@angular/core';
|
||||
import {MatPaginator} from '@angular/material/paginator';
|
||||
import {MatSort} from '@angular/material/sort';
|
||||
import {MatTable, MatTableDataSource} from '@angular/material/table';
|
||||
import {LibraryDto} from "../../models/dtos/LibraryDto";
|
||||
import {LibraryScanRequestDto} from "../../models/dtos/LibraryScanRequestDto";
|
||||
import {GamesService} from "../../services/games.service";
|
||||
import {LibraryManagementService} from "../../services/library-management.service";
|
||||
import {DialogService} from "../../services/dialog.service";
|
||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||
import {Router} from "@angular/router";
|
||||
import {LibraryService} from "../../services/library.service";
|
||||
|
||||
@Component({
|
||||
selector: 'mapped-libraries-table',
|
||||
templateUrl: './mapped-libraries-table.component.html',
|
||||
styleUrls: ['./mapped-libraries-table.component.scss']
|
||||
})
|
||||
export class MappedLibrariesTableComponent implements AfterViewInit, OnChanges {
|
||||
@ViewChild(MatPaginator) paginator!: MatPaginator;
|
||||
@ViewChild(MatSort) sort!: MatSort;
|
||||
@ViewChild(MatTable) table!: MatTable<LibraryDto>;
|
||||
@Input() mappedLibraries!: LibraryDto[];
|
||||
|
||||
dataSource: MatTableDataSource<LibraryDto> = new MatTableDataSource();
|
||||
|
||||
displayedColumns: string[] = ["path", "platforms", "actions"];
|
||||
|
||||
filter: LibraryDto = new LibraryDto();
|
||||
|
||||
constructor(private libraryManagementService: LibraryManagementService,
|
||||
private dialogService: DialogService,
|
||||
private libraryService: LibraryService,
|
||||
private snackBar: MatSnackBar,
|
||||
private router: Router) {
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.dataSource.sort = this.sort;
|
||||
this.dataSource.sortingDataAccessor = (item: LibraryDto, property: string) => {
|
||||
return (item as any)[property];
|
||||
};
|
||||
|
||||
this.dataSource.paginator = this.paginator;
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
this.refreshData(changes['mappedLibraries'].currentValue);
|
||||
}
|
||||
|
||||
refreshMappedLibrariesList(): void {
|
||||
this.libraryManagementService.getLibraries().subscribe(libraries => this.refreshData(libraries));
|
||||
}
|
||||
|
||||
openLibraryMappingDialog(mappedLibrary: LibraryDto): void {
|
||||
this.dialogService.libraryMappingDialog(mappedLibrary).subscribe(librarySuccessfullyMapped => {
|
||||
if (librarySuccessfullyMapped) this.refreshMappedLibrariesList();
|
||||
})
|
||||
}
|
||||
|
||||
scanLibrary(mappedLibrary: LibraryDto): void {
|
||||
let request = new LibraryScanRequestDto();
|
||||
request.path = mappedLibrary.path;
|
||||
request.downloadImages = true;
|
||||
this.libraryService.scanLibrary(request).subscribe({
|
||||
next: result => {
|
||||
// Refresh the current page "angular style"
|
||||
this.router.navigate([this.router.url]).then(() => {
|
||||
const snackBarDuration: number = 10000;
|
||||
|
||||
let snackbarContent: string = 'Library scan completed in ' + result.scanDuration + ' seconds:\n' +
|
||||
'- ' + result.newGames + ' new games\n' +
|
||||
'- ' + result.deletedGames + ' games removed\n' +
|
||||
'- ' + result.newUnmappableFiles + ' files/folders could not be mapped\n' +
|
||||
'- ' + result.totalGames + ' games currently in your library';
|
||||
|
||||
if (result.companyLogoDownloads !== undefined && result.coverDownloads !== undefined && result.screenshotDownloads !== undefined) {
|
||||
snackbarContent = snackbarContent.concat('\n' +
|
||||
'- ' + result.coverDownloads + ' covers downloaded\n' +
|
||||
'- ' + result.screenshotDownloads + ' screenshots downloaded\n' +
|
||||
'- ' + result.companyLogoDownloads + ' company logos downloaded');
|
||||
}
|
||||
|
||||
this.snackBar.open(snackbarContent, undefined, {duration: snackBarDuration});
|
||||
}
|
||||
)
|
||||
},
|
||||
error: error => this.snackBar.open(`Error while scanning library: ${error.error.message}`, undefined, {duration: 5000})
|
||||
})
|
||||
this.snackBar.open('Library scan started in the background. This could take some time.\nYou will get another notification once it\'s done', undefined, {duration: 5000})
|
||||
}
|
||||
|
||||
private refreshData(newData: LibraryDto[]): void {
|
||||
this.dataSource.data = newData;
|
||||
|
||||
// Dirty hack to force a re-render
|
||||
// Did not find a better solution
|
||||
this.paginator?._changePageSize(this.paginator?.pageSize);
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@ export class AutocompleteSuggestionDto {
|
||||
slug!: string;
|
||||
title!: string;
|
||||
releaseDate!: number;
|
||||
platforms!: Array<string>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import {CompanyDto} from "./CompanyDto";
|
||||
import {GenreDto} from "./GenreDto";
|
||||
import {KeywordDto} from "./KeywordDto";
|
||||
import {PlayerPerspectiveDto} from "./PlayerPerspectiveDto";
|
||||
import {PlatformDto} from "./PlatformDto";
|
||||
import {ThemeDto} from "./ThemeDto";
|
||||
import {LibraryDto} from "./LibraryDto";
|
||||
|
||||
export class DetectedGameDto {
|
||||
|
||||
@@ -26,6 +28,8 @@ export class DetectedGameDto {
|
||||
keywords?: KeywordDto[];
|
||||
themes?: ThemeDto[];
|
||||
playerPerspectives?: PlayerPerspectiveDto[];
|
||||
platforms?: PlatformDto[];
|
||||
library?: LibraryDto;
|
||||
|
||||
path!: string;
|
||||
diskSize!: number;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import {PlatformDto} from "./PlatformDto";
|
||||
|
||||
export class LibraryDto {
|
||||
path!: string;
|
||||
platforms!: PlatformDto[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import {PlatformDto} from "./PlatformDto";
|
||||
|
||||
export class LibraryScanRequestDto {
|
||||
path!: string;
|
||||
downloadImages!: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export class PlatformDto {
|
||||
slug!: string;
|
||||
name!: string;
|
||||
platformLogoId?: string;
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import {MatDialog, MatDialogConfig} from '@angular/material/dialog';
|
||||
import {ErrorDialogComponent} from '../components/error-dialog/error-dialog.component';
|
||||
import {DetectedGameDto} from "../models/dtos/DetectedGameDto";
|
||||
import {MapGameDialogComponent} from "../components/map-game-dialog/map-game-dialog.component";
|
||||
import {MapLibraryDialogComponent} from "../components/map-library-dialog/map-library-dialog.component";
|
||||
import {UnmappedFileDto} from "../models/dtos/UnmappedFileDto";
|
||||
import {LibraryDto} from "../models/dtos/LibraryDto";
|
||||
import {Observable} from "rxjs";
|
||||
|
||||
@Injectable({
|
||||
@@ -35,7 +37,7 @@ export class DialogService {
|
||||
dialogConfig.disableClose = true;
|
||||
dialogConfig.autoFocus = true;
|
||||
dialogConfig.closeOnNavigation = true;
|
||||
dialogConfig.minWidth = '25vw';
|
||||
dialogConfig.minWidth = '40vw';
|
||||
|
||||
dialogConfig.data = {
|
||||
path: game.path,
|
||||
@@ -51,7 +53,7 @@ export class DialogService {
|
||||
dialogConfig.disableClose = true;
|
||||
dialogConfig.autoFocus = true;
|
||||
dialogConfig.closeOnNavigation = true;
|
||||
dialogConfig.minWidth = '25vw';
|
||||
dialogConfig.minWidth = '40vw';
|
||||
|
||||
dialogConfig.data = {
|
||||
path: unmappedFile.path
|
||||
@@ -60,4 +62,20 @@ export class DialogService {
|
||||
return this.dialog.open(MapGameDialogComponent, dialogConfig).afterClosed();
|
||||
}
|
||||
|
||||
public libraryMappingDialog(library: LibraryDto): Observable<any> {
|
||||
const dialogConfig = new MatDialogConfig();
|
||||
|
||||
dialogConfig.disableClose = true;
|
||||
dialogConfig.autoFocus = true;
|
||||
dialogConfig.closeOnNavigation = true;
|
||||
dialogConfig.minWidth = '40vw';
|
||||
|
||||
dialogConfig.data = {
|
||||
path: library.path,
|
||||
slugs: library.platforms.map((platform) => platform.slug)
|
||||
};
|
||||
|
||||
return this.dialog.open(MapLibraryDialogComponent, dialogConfig).afterClosed();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {distinct, map, Observable} from "rxjs";
|
||||
import {DetectedGameDto} from "../models/dtos/DetectedGameDto";
|
||||
import {GameOverviewDto} from "../models/dtos/GameOverviewDto";
|
||||
import {GenreDto} from "../models/dtos/GenreDto";
|
||||
import {PlatformDto} from "../models/dtos/PlatformDto";
|
||||
import {ThemeDto} from "../models/dtos/ThemeDto";
|
||||
import {CompanyDto} from "../models/dtos/CompanyDto";
|
||||
import {PlayerPerspectiveDto} from "../models/dtos/PlayerPerspectiveDto";
|
||||
@@ -88,6 +89,20 @@ export class GamesService implements GamesApi {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: This method of removing duplicates is most certainly an anti-pattern in RxJS
|
||||
// TODO: However, I did not get the 'distinct()' pipe to work properly, so I have to take another look in the future
|
||||
getAvailablePlatforms(): Observable<PlatformDto[]> {
|
||||
return this.getAllGames().pipe(
|
||||
map<DetectedGameDto[], PlatformDto[]>(
|
||||
games => {
|
||||
let availablePlatformsMap: Map<string, PlatformDto> = new Map<string, PlatformDto>;
|
||||
games.map(game => game.library !== undefined && game.library.platforms.length > 0 ? game.library.platforms : []).flat().forEach(platform => availablePlatformsMap.set(platform.slug, platform));
|
||||
return Array.from(availablePlatformsMap.values()).sort((p1, p2) => p1.name.localeCompare(p2.name));
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
downloadGame(slug: String): void {
|
||||
window.open(`v1${this.apiPath}/game/${slug}/download`, '_top');
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {UnmappedFileDto} from "../models/dtos/UnmappedFileDto";
|
||||
import {LibraryManagementApi} from "../api/LibraryManagementApi";
|
||||
import {GamesService} from "./games.service";
|
||||
import {AutocompleteSuggestionDto} from "../models/dtos/AutocompleteSuggestionDto";
|
||||
import {LibraryDto} from "../models/dtos/LibraryDto";
|
||||
import {PlatformDto} from "../models/dtos/PlatformDto";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -50,4 +52,20 @@ export class LibraryManagementService implements LibraryManagementApi {
|
||||
|
||||
return this.http.get<AutocompleteSuggestionDto[]>(`${this.apiPath}/autocomplete-suggestions`, {params:queryParams})
|
||||
}
|
||||
|
||||
getPlatforms(searchTerm: string, limit: number): Observable<PlatformDto[]> {
|
||||
let queryParams = new HttpParams();
|
||||
queryParams = queryParams.append("searchTerm", searchTerm);
|
||||
queryParams = queryParams.append("limit", limit);
|
||||
|
||||
return this.http.get<PlatformDto[]>(`${this.apiPath}/platforms`, {params:queryParams})
|
||||
}
|
||||
|
||||
mapLibrary(pathToSlugDto: PathToSlugDto): Observable<LibraryDto> {
|
||||
return this.http.post<LibraryDto>(`${this.apiPath}/map-library`, pathToSlugDto);
|
||||
}
|
||||
|
||||
getLibraries(): Observable<LibraryDto[]> {
|
||||
return this.http.get<LibraryDto[]>(`${this.apiPath}/libraries`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {Observable} from "rxjs";
|
||||
import {LibraryApi} from "../api/LibraryApi";
|
||||
import {LibraryScanResultDto} from "../models/dtos/LibraryScanResultDto";
|
||||
import {ImageDownloadResultDto} from "../models/dtos/ImageDownloadResultDto";
|
||||
import {LibraryDto} from "../models/dtos/LibraryDto";
|
||||
import {LibraryScanRequestDto} from "../models/dtos/LibraryScanRequestDto";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -15,8 +17,8 @@ export class LibraryService implements LibraryApi {
|
||||
constructor(private http: HttpClient) {
|
||||
}
|
||||
|
||||
scanLibrary(): Observable<LibraryScanResultDto> {
|
||||
return this.http.get<LibraryScanResultDto>(`${this.apiPath}/scan`);
|
||||
scanLibrary(library: LibraryScanRequestDto): Observable<LibraryScanResultDto> {
|
||||
return this.http.post<LibraryScanResultDto>(`${this.apiPath}/scan`, library);
|
||||
}
|
||||
|
||||
downloadImages(): Observable<ImageDownloadResultDto> {
|
||||
|
||||
Reference in New Issue
Block a user