diff --git a/backend/pom.xml b/backend/pom.xml index 46f0df7..b0532a0 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -5,7 +5,7 @@ gameyfin de.grimsi - 1.2.6-SNAPSHOT + 1.3.0-SNAPSHOT gameyfin-backend diff --git a/backend/src/main/java/de/grimsi/gameyfin/dto/AutocompleteSuggestionDto.java b/backend/src/main/java/de/grimsi/gameyfin/dto/AutocompleteSuggestionDto.java index 5bfcd06..3f0f816 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/dto/AutocompleteSuggestionDto.java +++ b/backend/src/main/java/de/grimsi/gameyfin/dto/AutocompleteSuggestionDto.java @@ -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 platforms; } diff --git a/backend/src/main/java/de/grimsi/gameyfin/dto/LibraryScanRequestDto.java b/backend/src/main/java/de/grimsi/gameyfin/dto/LibraryScanRequestDto.java new file mode 100644 index 0000000..66da3ec --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/dto/LibraryScanRequestDto.java @@ -0,0 +1,9 @@ +package de.grimsi.gameyfin.dto; + +import lombok.Data; + +@Data +public class LibraryScanRequestDto { + private String path; + private boolean downloadImages; +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java b/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java index 7435cba..1db42df 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java +++ b/backend/src/main/java/de/grimsi/gameyfin/entities/DetectedGame.java @@ -77,6 +77,14 @@ public class DetectedGame { @ToString.Exclude private List playerPerspectives; + @ManyToMany(cascade = CascadeType.MERGE) + @ToString.Exclude + private List platforms; + + @ManyToOne + @JoinColumn(name = "library") + private Library library; + // Technical properties @Column(nullable = false) private String path; diff --git a/backend/src/main/java/de/grimsi/gameyfin/entities/Library.java b/backend/src/main/java/de/grimsi/gameyfin/entities/Library.java new file mode 100644 index 0000000..7b94396 --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/entities/Library.java @@ -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 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(); + } +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/entities/Platform.java b/backend/src/main/java/de/grimsi/gameyfin/entities/Platform.java new file mode 100644 index 0000000..0569716 --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/entities/Platform.java @@ -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(); + } +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbApiProperties.java b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbApiProperties.java index ed497dd..35a6370 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbApiProperties.java +++ b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbApiProperties.java @@ -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 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); diff --git a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbApiQueryBuilder.java b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbApiQueryBuilder.java new file mode 100644 index 0000000..aa9f2f2 --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbApiQueryBuilder.java @@ -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))); + } + } +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java index c65d45b..55dccfb 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java +++ b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java @@ -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 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 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 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 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 searchForGameByTitle(String searchTerm) { + return searchForGameByTitle(searchTerm, List.of()); + } + + public Optional searchForGameByTitle(String searchTerm, Collection 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 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); + } } diff --git a/backend/src/main/java/de/grimsi/gameyfin/mapper/GameMapper.java b/backend/src/main/java/de/grimsi/gameyfin/mapper/GameMapper.java index bc4ddb3..389a688 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/mapper/GameMapper.java +++ b/backend/src/main/java/de/grimsi/gameyfin/mapper/GameMapper.java @@ -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 multiplayerModes = g.getMultiplayerModesList(); List screenshotIds = g.getScreenshotsList().stream().map(Igdb.Screenshot::getImageId).toList(); List 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; } diff --git a/backend/src/main/java/de/grimsi/gameyfin/mapper/PlatformMapper.java b/backend/src/main/java/de/grimsi/gameyfin/mapper/PlatformMapper.java new file mode 100644 index 0000000..8cfca2d --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/mapper/PlatformMapper.java @@ -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 toPlatforms(List c) { + return c.stream().map(PlatformMapper::toPlatform).toList(); + } +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/repositories/DetectedGameRepository.java b/backend/src/main/java/de/grimsi/gameyfin/repositories/DetectedGameRepository.java index 433044a..09a9eaf 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/repositories/DetectedGameRepository.java +++ b/backend/src/main/java/de/grimsi/gameyfin/repositories/DetectedGameRepository.java @@ -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 { @@ -16,10 +21,17 @@ public interface DetectedGameRepository extends JpaRepository findByPath(String path); - List getAllByPathNotIn(Collection paths); + List findByPathStartsWithAndLibraryIsNull(String path); - default List getAllByPathNotIn(List paths) { + List getAllByPathNotIn(Collection paths); + List getAllByPathNotInAndPathStartsWith(Collection paths, String libraryPath); + + default List getAllByPathNotInAndPathStartsWith(List paths, String libraryPath) { List 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); } + + + } diff --git a/backend/src/main/java/de/grimsi/gameyfin/repositories/LibraryRepository.java b/backend/src/main/java/de/grimsi/gameyfin/repositories/LibraryRepository.java new file mode 100644 index 0000000..1837fbb --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/repositories/LibraryRepository.java @@ -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 { + + boolean existsByPathIgnoreCase(String path); + + Optional findByPath(String path); +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/repositories/PlatformRepository.java b/backend/src/main/java/de/grimsi/gameyfin/repositories/PlatformRepository.java new file mode 100644 index 0000000..869a53d --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/repositories/PlatformRepository.java @@ -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 { + + boolean existsBySlug(String slug); + + Optional findBySlug(String slug); +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/repositories/UnmappableFileRepository.java b/backend/src/main/java/de/grimsi/gameyfin/repositories/UnmappableFileRepository.java index bcdb1c0..c1f6a6b 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/repositories/UnmappableFileRepository.java +++ b/backend/src/main/java/de/grimsi/gameyfin/repositories/UnmappableFileRepository.java @@ -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 { @@ -14,10 +17,14 @@ public interface UnmappableFileRepository extends JpaRepository getAllByPathNotIn(Collection paths); + List getAllByPathNotInAndPathStartsWith(Collection paths, String libraryPath); + + Optional findByPath(String path); - default List getAllByPathNotIn(List paths) { + default List getAllByPathNotInAndPathStartsWith(List paths, String libraryPath) { List 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); } } diff --git a/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryController.java b/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryController.java index 0643817..e5bcd79 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryController.java +++ b/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryController.java @@ -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 libraries = isNotBlank(path) ? List.of(libraryService.getLibrary(path)) : libraryService.getLibraries(); + List 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()); diff --git a/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryManagementController.java b/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryManagementController.java index 05d9da0..42bbab6 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryManagementController.java +++ b/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryManagementController.java @@ -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 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 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 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()); + } + } diff --git a/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java b/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java index 4dc8d2f..8b6ccce 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java +++ b/backend/src/main/java/de/grimsi/gameyfin/service/GameService.java @@ -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 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); } } diff --git a/backend/src/main/java/de/grimsi/gameyfin/service/LibraryService.java b/backend/src/main/java/de/grimsi/gameyfin/service/LibraryService.java index 9ecf99d..466a37f 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/service/LibraryService.java +++ b/backend/src/main/java/de/grimsi/gameyfin/service/LibraryService.java @@ -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 getGameFiles() { + return getGameFiles(null); + } + + public List getGameFiles(String path) { List gamefiles = new ArrayList<>(); - libraryFolders.stream().map(Path::of).forEach( + libraryFolders.stream().map(Path::of).filter(allPathsOrSpecific(path)).forEach( folder -> { try (Stream 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 s = Files.newDirectoryStream(p)) { + try (DirectoryStream 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 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 gameFiles = getGameFiles(); + String libraryPath = library.getPath(); + List 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 deletedGames = detectedGameRepository.getAllByPathNotIn(gameFiles); + List 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 deletedUnmappableFiles = unmappableFileRepository.getAllByPathNotIn(gameFiles); + List 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 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 newDetectedGames = gameFiles.parallelStream() .map(p -> { - Optional optionalGame = igdbWrapper.searchForGameByTitle(getFilenameWithoutExtension(p)); + Optional 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 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 unmappableFiles = newDetectedGames.stream() + .map(game -> new UnmappableFile(game.getPath())).toList(); + unmappableFileRepository.saveAll(unmappableFiles); + } stopWatch.stop(); @@ -158,11 +192,60 @@ public class LibraryService { } private List getDuplicates(List 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 getLibraries() { + return libraryRepository.findAll(); + } + + public Library getLibrary(String path) { + return libraryRepository.findByPath(path).orElse(null); + } + + public List 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 libraries = libraryRepository.findAll(); + libraries.forEach(library -> { + // remap existing games to this library as well + List gamesWithoutLibraryAssignment = + detectedGameRepository.findByPathStartsWithAndLibraryIsNull(library.getPath()); + gamesWithoutLibraryAssignment.forEach(game -> game.setLibrary(library)); + detectedGameRepository.saveAll(gamesWithoutLibraryAssignment); + }); + return libraries; + } + + public List 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 platformSlugs = Arrays.stream(slugs.split(",")).collect(toSet()); + List platforms = platformSlugs.stream() + .map(slug -> platformRepository.findBySlug(slug). + orElseGet(() -> igdbWrapper.getPlatformBySlug(slug))) + .filter(Objects::nonNull) + .toList(); + library.setPlatforms(platforms); + libraryRepository.save(library); + + return library; + } } diff --git a/backend/src/main/resources/db/migration/V1_3_0__Add_Platforms_to_Detected_Game.sql b/backend/src/main/resources/db/migration/V1_3_0__Add_Platforms_to_Detected_Game.sql new file mode 100644 index 0000000..6b6b67c --- /dev/null +++ b/backend/src/main/resources/db/migration/V1_3_0__Add_Platforms_to_Detected_Game.sql @@ -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); \ No newline at end of file diff --git a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java index 458ed02..7d20b82 100644 --- a/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java +++ b/backend/src/test/java/de/grimsi/gameyfin/igdb/IgdbWrapperTest.java @@ -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); } diff --git a/config/gameyfin.properties b/config/gameyfin.properties index eeeae45..7ba1b15 100644 --- a/config/gameyfin.properties +++ b/config/gameyfin.properties @@ -16,4 +16,15 @@ gameyfin.sources= # Your twitch client-id and client-secret gameyfin.igdb.api.client-id= -gameyfin.igdb.api.client-secret= \ No newline at end of file +gameyfin.igdb.api.client-secret= + +# 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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5a5e7dc..27f0ad6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index a828c86..49d0436 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.2.5-SNAPSHOT", + "version": "1.3.0-SNAPSHOT", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/frontend/pom.xml b/frontend/pom.xml index 21eb464..2a4b60d 100644 --- a/frontend/pom.xml +++ b/frontend/pom.xml @@ -3,7 +3,7 @@ gameyfin de.grimsi - 1.2.6-SNAPSHOT + 1.3.0-SNAPSHOT 4.0.0 diff --git a/frontend/src/app/api/LibraryApi.ts b/frontend/src/app/api/LibraryApi.ts index 9c3eda4..92863b8 100644 --- a/frontend/src/app/api/LibraryApi.ts +++ b/frontend/src/app/api/LibraryApi.ts @@ -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; + scanLibrary(mappedLibrary: LibraryScanRequestDto): Observable; downloadImages(): Observable; diff --git a/frontend/src/app/api/LibraryManagementApi.ts b/frontend/src/app/api/LibraryManagementApi.ts index 26a185e..1d8157f 100644 --- a/frontend/src/app/api/LibraryManagementApi.ts +++ b/frontend/src/app/api/LibraryManagementApi.ts @@ -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; @@ -11,4 +12,5 @@ export interface LibraryManagementApi { deleteGame(slug: string): Observable; deleteUnmappedFile(id: number): Observable; getAutocompleteSuggestions(searchTerm: string, limit: number): Observable; + getLibraries(): Observable; } diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 266abab..01c3922 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -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, diff --git a/frontend/src/app/components/game-detail-view/game-detail-view.component.html b/frontend/src/app/components/game-detail-view/game-detail-view.component.html index f5b284b..d802109 100644 --- a/frontend/src/app/components/game-detail-view/game-detail-view.component.html +++ b/frontend/src/app/components/game-detail-view/game-detail-view.component.html @@ -58,6 +58,14 @@ (click)="goToLibraryWithFilter('themes', theme.slug)">{{theme.name}} + +
+

Platforms

+ + {{platform.name}} + +
diff --git a/frontend/src/app/components/game-detail-view/game-detail-view.component.ts b/frontend/src/app/components/game-detail-view/game-detail-view.component.ts index 88f6db7..abaa756 100644 --- a/frontend/src/app/components/game-detail-view/game-detail-view.component.ts +++ b/frontend/src/app/components/game-detail-view/game-detail-view.component.ts @@ -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) + } + } diff --git a/frontend/src/app/components/header/header.component.ts b/frontend/src/app/components/header/header.component.ts index eb3e802..46ccf6a 100644 --- a/frontend/src/app/components/header/header.component.ts +++ b/frontend/src/app/components/header/header.component.ts @@ -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(() => { diff --git a/frontend/src/app/components/library-management/library-management.component.html b/frontend/src/app/components/library-management/library-management.component.html index 6f20987..43bd999 100644 --- a/frontend/src/app/components/library-management/library-management.component.html +++ b/frontend/src/app/components/library-management/library-management.component.html @@ -1,6 +1,9 @@
-
+
+ + + @@ -19,7 +22,7 @@
-
+
north_east

Use the library management to scan your file system for games

diff --git a/frontend/src/app/components/library-management/library-management.component.ts b/frontend/src/app/components/library-management/library-management.component.ts index 0e9c25a..ffe987f 100644 --- a/frontend/src/app/components/library-management/library-management.component.ts +++ b/frontend/src/app/components/library-management/library-management.component.ts @@ -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; + }); } } diff --git a/frontend/src/app/components/library-overview/library-overview.component.html b/frontend/src/app/components/library-overview/library-overview.component.html index e4bbf04..292ddf9 100644 --- a/frontend/src/app/components/library-overview/library-overview.component.html +++ b/frontend/src/app/components/library-overview/library-overview.component.html @@ -114,6 +114,18 @@ color="primary">{{playerPerspective.name}}
+ + + +

Platforms

+
+ +
+ {{platform.name}} +
+
diff --git a/frontend/src/app/components/library-overview/library-overview.component.ts b/frontend/src/app/components/library-overview/library-overview.component.ts index c7a556a..4e16e5f 100644 --- a/frontend/src/app/components/library-overview/library-overview.component.ts +++ b/frontend/src/app/components/library-overview/library-overview.component.ts @@ -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 = this.gameServerService.getAvailableGenres(); let themeObservable: Observable = this.gameServerService.getAvailableThemes(); let playerPerspectiveObservable: Observable = this.gameServerService.getAvailablePlayerPerspectives(); + let platformObservable: Observable = 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)) { diff --git a/frontend/src/app/components/map-game-dialog/map-game-dialog.component.html b/frontend/src/app/components/map-game-dialog/map-game-dialog.component.html index dac89ed..2826684 100644 --- a/frontend/src/app/components/map-game-dialog/map-game-dialog.component.html +++ b/frontend/src/app/components/map-game-dialog/map-game-dialog.component.html @@ -12,7 +12,7 @@ - {{suggestion.title}} ({{getFullYearFromTimestamp(suggestion.releaseDate)}}) + {{suggestion.title}} ({{getFullYearFromTimestamp(suggestion.releaseDate)}}) - {{suggestion.platforms.join(', ')}} diff --git a/frontend/src/app/components/map-library-dialog/map-library-dialog.component.html b/frontend/src/app/components/map-library-dialog/map-library-dialog.component.html new file mode 100644 index 0000000..d593f4b --- /dev/null +++ b/frontend/src/app/components/map-library-dialog/map-library-dialog.component.html @@ -0,0 +1,31 @@ +

Map path to IGDB platform

+ +
+ +

Path: {{path}}

+

Available platforms

+ + +
+ + +
+ + + + {{suggestion.name}} + + +
+ +
+
+ + + + diff --git a/frontend/src/app/components/map-library-dialog/map-library-dialog.component.scss b/frontend/src/app/components/map-library-dialog/map-library-dialog.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/components/map-library-dialog/map-library-dialog.component.spec.ts b/frontend/src/app/components/map-library-dialog/map-library-dialog.component.spec.ts new file mode 100644 index 0000000..a55f4d0 --- /dev/null +++ b/frontend/src/app/components/map-library-dialog/map-library-dialog.component.spec.ts @@ -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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ MapLibraryDialogComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(MapLibraryDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/map-library-dialog/map-library-dialog.component.ts b/frontend/src/app/components/map-library-dialog/map-library-dialog.component.ts new file mode 100644 index 0000000..01805b9 --- /dev/null +++ b/frontend/src/app/components/map-library-dialog/map-library-dialog.component.ts @@ -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, + @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 + }) + } +} diff --git a/frontend/src/app/components/mapped-libraries-table/mapped-libraries-table.component.html b/frontend/src/app/components/mapped-libraries-table/mapped-libraries-table.component.html new file mode 100644 index 0000000..c86c728 --- /dev/null +++ b/frontend/src/app/components/mapped-libraries-table/mapped-libraries-table.component.html @@ -0,0 +1,43 @@ +
+ + + + + + + + + + + + + + + + + + + + + + +
Path {{element.path}} Platforms{{item.name}}{{isLast ? '' : ', '}} + + + + +
+ + + +
diff --git a/frontend/src/app/components/mapped-libraries-table/mapped-libraries-table.component.scss b/frontend/src/app/components/mapped-libraries-table/mapped-libraries-table.component.scss new file mode 100644 index 0000000..88a79ab --- /dev/null +++ b/frontend/src/app/components/mapped-libraries-table/mapped-libraries-table.component.scss @@ -0,0 +1,8 @@ +table { + width: 50vw; + min-width: 750px; +} + +.mat-column-actions { + width: 20%; +} diff --git a/frontend/src/app/components/mapped-libraries-table/mapped-libraries-table.component.spec.ts b/frontend/src/app/components/mapped-libraries-table/mapped-libraries-table.component.spec.ts new file mode 100644 index 0000000..bd3413e --- /dev/null +++ b/frontend/src/app/components/mapped-libraries-table/mapped-libraries-table.component.spec.ts @@ -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; + + 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(); + }); +}); diff --git a/frontend/src/app/components/mapped-libraries-table/mapped-libraries-table.component.ts b/frontend/src/app/components/mapped-libraries-table/mapped-libraries-table.component.ts new file mode 100644 index 0000000..46ae844 --- /dev/null +++ b/frontend/src/app/components/mapped-libraries-table/mapped-libraries-table.component.ts @@ -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; + @Input() mappedLibraries!: LibraryDto[]; + + dataSource: MatTableDataSource = 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); + } +} diff --git a/frontend/src/app/models/dtos/AutocompleteSuggestionDto.ts b/frontend/src/app/models/dtos/AutocompleteSuggestionDto.ts index f4386a2..9c7b024 100644 --- a/frontend/src/app/models/dtos/AutocompleteSuggestionDto.ts +++ b/frontend/src/app/models/dtos/AutocompleteSuggestionDto.ts @@ -2,4 +2,5 @@ export class AutocompleteSuggestionDto { slug!: string; title!: string; releaseDate!: number; + platforms!: Array; } diff --git a/frontend/src/app/models/dtos/DetectedGameDto.ts b/frontend/src/app/models/dtos/DetectedGameDto.ts index ccb7bff..61e2179 100644 --- a/frontend/src/app/models/dtos/DetectedGameDto.ts +++ b/frontend/src/app/models/dtos/DetectedGameDto.ts @@ -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; diff --git a/frontend/src/app/models/dtos/LibraryDto.ts b/frontend/src/app/models/dtos/LibraryDto.ts new file mode 100644 index 0000000..784dfbe --- /dev/null +++ b/frontend/src/app/models/dtos/LibraryDto.ts @@ -0,0 +1,7 @@ +import {PlatformDto} from "./PlatformDto"; + +export class LibraryDto { + path!: string; + platforms!: PlatformDto[]; +} + diff --git a/frontend/src/app/models/dtos/LibraryScanRequestDto.ts b/frontend/src/app/models/dtos/LibraryScanRequestDto.ts new file mode 100644 index 0000000..3613d30 --- /dev/null +++ b/frontend/src/app/models/dtos/LibraryScanRequestDto.ts @@ -0,0 +1,7 @@ +import {PlatformDto} from "./PlatformDto"; + +export class LibraryScanRequestDto { + path!: string; + downloadImages!: boolean; +} + diff --git a/frontend/src/app/models/dtos/PlatformDto.ts b/frontend/src/app/models/dtos/PlatformDto.ts new file mode 100644 index 0000000..c3d9619 --- /dev/null +++ b/frontend/src/app/models/dtos/PlatformDto.ts @@ -0,0 +1,5 @@ +export class PlatformDto { + slug!: string; + name!: string; + platformLogoId?: string; +} diff --git a/frontend/src/app/services/dialog.service.ts b/frontend/src/app/services/dialog.service.ts index 5e5eec8..7513241 100644 --- a/frontend/src/app/services/dialog.service.ts +++ b/frontend/src/app/services/dialog.service.ts @@ -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 { + 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(); + } + } diff --git a/frontend/src/app/services/games.service.ts b/frontend/src/app/services/games.service.ts index ea7a5b8..87a9776 100644 --- a/frontend/src/app/services/games.service.ts +++ b/frontend/src/app/services/games.service.ts @@ -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 { + return this.getAllGames().pipe( + map( + games => { + let availablePlatformsMap: Map = new Map; + 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'); } diff --git a/frontend/src/app/services/library-management.service.ts b/frontend/src/app/services/library-management.service.ts index d36eb4c..e9ef5f3 100644 --- a/frontend/src/app/services/library-management.service.ts +++ b/frontend/src/app/services/library-management.service.ts @@ -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(`${this.apiPath}/autocomplete-suggestions`, {params:queryParams}) } + + getPlatforms(searchTerm: string, limit: number): Observable { + let queryParams = new HttpParams(); + queryParams = queryParams.append("searchTerm", searchTerm); + queryParams = queryParams.append("limit", limit); + + return this.http.get(`${this.apiPath}/platforms`, {params:queryParams}) + } + + mapLibrary(pathToSlugDto: PathToSlugDto): Observable { + return this.http.post(`${this.apiPath}/map-library`, pathToSlugDto); + } + + getLibraries(): Observable { + return this.http.get(`${this.apiPath}/libraries`); + } } diff --git a/frontend/src/app/services/library.service.ts b/frontend/src/app/services/library.service.ts index 3fd7d82..3c07159 100644 --- a/frontend/src/app/services/library.service.ts +++ b/frontend/src/app/services/library.service.ts @@ -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 { - return this.http.get(`${this.apiPath}/scan`); + scanLibrary(library: LibraryScanRequestDto): Observable { + return this.http.post(`${this.apiPath}/scan`, library); } downloadImages(): Observable { diff --git a/pom.xml b/pom.xml index 1169c07..1dcd1ca 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ de.grimsi gameyfin - 1.2.6-SNAPSHOT + 1.3.0-SNAPSHOT gameyfin gameyfin