mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 16:20:04 +00:00
Polishing and code clean-up
This commit is contained in:
+22
-9
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<artifactId>gameyfin</artifactId>
|
<artifactId>gameyfin</artifactId>
|
||||||
<groupId>de.grimsi</groupId>
|
<groupId>de.grimsi</groupId>
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
<version>1.0.0</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>backend</artifactId>
|
<artifactId>backend</artifactId>
|
||||||
@@ -15,13 +15,11 @@
|
|||||||
<properties>
|
<properties>
|
||||||
<java.version>18</java.version>
|
<java.version>18</java.version>
|
||||||
<springdoc-openapi-ui.version>1.6.9</springdoc-openapi-ui.version>
|
<springdoc-openapi-ui.version>1.6.9</springdoc-openapi-ui.version>
|
||||||
<resilience4j-reactor.version>1.7.1</resilience4j-reactor.version>
|
<resilience4j.version>1.7.1</resilience4j.version>
|
||||||
<commons-io.version>2.11.0</commons-io.version>
|
<commons-io.version>2.11.0</commons-io.version>
|
||||||
<protoc.plugin.version>3.11.4</protoc.plugin.version>
|
|
||||||
<protobuf-java.version>3.21.2</protobuf-java.version>
|
|
||||||
<gameyfin-frontend.version>0.0.1-SNAPSHOT</gameyfin-frontend.version>
|
|
||||||
<commons-compress.version>1.21</commons-compress.version>
|
<commons-compress.version>1.21</commons-compress.version>
|
||||||
<java-jwt.version>4.0.0</java-jwt.version>
|
<protoc.plugin.version>3.11.4</protoc.plugin.version>
|
||||||
|
<protobuf-java.version>3.21.3</protobuf-java.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
@@ -50,17 +48,17 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.github.resilience4j</groupId>
|
<groupId>io.github.resilience4j</groupId>
|
||||||
<artifactId>resilience4j-reactor</artifactId>
|
<artifactId>resilience4j-reactor</artifactId>
|
||||||
<version>${resilience4j-reactor.version}</version>
|
<version>${resilience4j.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.github.resilience4j</groupId>
|
<groupId>io.github.resilience4j</groupId>
|
||||||
<artifactId>resilience4j-ratelimiter</artifactId>
|
<artifactId>resilience4j-ratelimiter</artifactId>
|
||||||
<version>${resilience4j-reactor.version}</version>
|
<version>${resilience4j.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.github.resilience4j</groupId>
|
<groupId>io.github.resilience4j</groupId>
|
||||||
<artifactId>resilience4j-bulkhead</artifactId>
|
<artifactId>resilience4j-bulkhead</artifactId>
|
||||||
<version>${resilience4j-reactor.version}</version>
|
<version>${resilience4j.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Security -->
|
<!-- Security -->
|
||||||
@@ -123,6 +121,21 @@
|
|||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
<resources>
|
||||||
|
<resource>
|
||||||
|
<directory>${basedir}/src/main/resources</directory>
|
||||||
|
<filtering>true</filtering>
|
||||||
|
<includes>
|
||||||
|
<include>**/*.properties</include>
|
||||||
|
<include>**/*.yml</include>
|
||||||
|
<include>**/*.yaml</include>
|
||||||
|
<include>**/*.txt</include>
|
||||||
|
<include>**/*.js</include>
|
||||||
|
<include>**/*.css</include>
|
||||||
|
<include>**/*.html</include>
|
||||||
|
</includes>
|
||||||
|
</resource>
|
||||||
|
</resources>
|
||||||
<plugins>
|
<plugins>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package de.grimsi.gameyfin.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.env.ConfigurableEnvironment;
|
||||||
|
import org.springframework.core.env.PropertiesPropertySource;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.support.PropertiesLoaderUtils;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SecureProperties {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public void setConfigurableEnvironment(ConfigurableEnvironment env) {
|
||||||
|
try {
|
||||||
|
Resource resource = new ClassPathResource("/config/secure.properties");
|
||||||
|
env.getPropertySources().addFirst(new PropertiesPropertySource(Objects.requireNonNull(resource.getFilename()), PropertiesLoaderUtils.loadProperties(resource)));
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new RuntimeException(ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.grimsi.gameyfin.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class AutocompleteSuggestionDto {
|
||||||
|
private String slug;
|
||||||
|
private String title;
|
||||||
|
private Instant releaseDate;
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package de.grimsi.gameyfin.igdb;
|
|||||||
|
|
||||||
import com.igdb.proto.Igdb;
|
import com.igdb.proto.Igdb;
|
||||||
import de.grimsi.gameyfin.config.WebClientConfig;
|
import de.grimsi.gameyfin.config.WebClientConfig;
|
||||||
|
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
|
||||||
import de.grimsi.gameyfin.igdb.dto.TwitchOAuthTokenDto;
|
import de.grimsi.gameyfin.igdb.dto.TwitchOAuthTokenDto;
|
||||||
|
import de.grimsi.gameyfin.mapper.GameMapper;
|
||||||
import io.github.resilience4j.reactor.bulkhead.operator.BulkheadOperator;
|
import io.github.resilience4j.reactor.bulkhead.operator.BulkheadOperator;
|
||||||
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
|
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -15,6 +17,7 @@ import org.springframework.web.util.UriComponentsBuilder;
|
|||||||
|
|
||||||
import javax.annotation.PostConstruct;
|
import javax.annotation.PostConstruct;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
@@ -91,6 +94,18 @@ public class IgdbWrapper {
|
|||||||
return Optional.of(gameResult.getGames(0));
|
return Optional.of(gameResult.getGames(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<AutocompleteSuggestionDto> findPossibleMatchingTitles(String searchTerm, int limit) {
|
||||||
|
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),
|
||||||
|
Igdb.GameResult.class
|
||||||
|
);
|
||||||
|
|
||||||
|
if(gameResult == null) return Collections.emptyList();
|
||||||
|
|
||||||
|
return gameResult.getGamesList().stream().map(GameMapper::toAutocompleteSuggestionDto).toList();
|
||||||
|
}
|
||||||
|
|
||||||
public Optional<Igdb.Game> searchForGameByTitle(String searchTerm) {
|
public Optional<Igdb.Game> searchForGameByTitle(String searchTerm) {
|
||||||
Igdb.GameResult gameResult = queryIgdbApi(
|
Igdb.GameResult gameResult = queryIgdbApi(
|
||||||
IgdbApiProperties.ENPOINT_GAMES_PROTOBUF,
|
IgdbApiProperties.ENPOINT_GAMES_PROTOBUF,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.grimsi.gameyfin.mapper;
|
package de.grimsi.gameyfin.mapper;
|
||||||
|
|
||||||
import com.igdb.proto.Igdb;
|
import com.igdb.proto.Igdb;
|
||||||
|
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
|
||||||
import de.grimsi.gameyfin.dto.GameOverviewDto;
|
import de.grimsi.gameyfin.dto.GameOverviewDto;
|
||||||
import de.grimsi.gameyfin.entities.DetectedGame;
|
import de.grimsi.gameyfin.entities.DetectedGame;
|
||||||
import de.grimsi.gameyfin.service.LibraryService;
|
import de.grimsi.gameyfin.service.LibraryService;
|
||||||
@@ -60,6 +61,14 @@ public class GameMapper {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static AutocompleteSuggestionDto toAutocompleteSuggestionDto(Igdb.Game game) {
|
||||||
|
return AutocompleteSuggestionDto.builder()
|
||||||
|
.slug(game.getSlug())
|
||||||
|
.title(game.getName())
|
||||||
|
.releaseDate(ProtobufUtil.toInstant(game.getFirstReleaseDate()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
private static String getCoverId(Igdb.Game g) {
|
private static String getCoverId(Igdb.Game g) {
|
||||||
String coverId = g.getCover().getImageId();
|
String coverId = g.getCover().getImageId();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.grimsi.gameyfin.rest;
|
|||||||
import de.grimsi.gameyfin.service.DownloadService;
|
import de.grimsi.gameyfin.service.DownloadService;
|
||||||
import de.grimsi.gameyfin.service.LibraryService;
|
import de.grimsi.gameyfin.service.LibraryService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -20,6 +21,7 @@ import java.util.List;
|
|||||||
@RequestMapping("/v1/library")
|
@RequestMapping("/v1/library")
|
||||||
@PreAuthorize("hasAuthority('ADMIN_API_ACCESS')")
|
@PreAuthorize("hasAuthority('ADMIN_API_ACCESS')")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class LibraryController {
|
public class LibraryController {
|
||||||
|
|
||||||
private final LibraryService libraryService;
|
private final LibraryService libraryService;
|
||||||
@@ -37,6 +39,8 @@ public class LibraryController {
|
|||||||
downloadService.downloadGameCoversFromIgdb();
|
downloadService.downloadGameCoversFromIgdb();
|
||||||
downloadService.downloadGameScreenshotsFromIgdb();
|
downloadService.downloadGameScreenshotsFromIgdb();
|
||||||
downloadService.downloadCompanyLogosFromIgdb();
|
downloadService.downloadCompanyLogosFromIgdb();
|
||||||
|
|
||||||
|
log.info("Downloading images completed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = "/files", produces = MediaType.APPLICATION_JSON_VALUE)
|
@GetMapping(value = "/files", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package de.grimsi.gameyfin.rest;
|
package de.grimsi.gameyfin.rest;
|
||||||
|
|
||||||
|
|
||||||
|
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
|
||||||
import de.grimsi.gameyfin.dto.PathToSlugDto;
|
import de.grimsi.gameyfin.dto.PathToSlugDto;
|
||||||
import de.grimsi.gameyfin.entities.DetectedGame;
|
import de.grimsi.gameyfin.entities.DetectedGame;
|
||||||
import de.grimsi.gameyfin.entities.UnmappableFile;
|
import de.grimsi.gameyfin.entities.UnmappableFile;
|
||||||
import de.grimsi.gameyfin.repositories.DetectedGameRepository;
|
import de.grimsi.gameyfin.repositories.DetectedGameRepository;
|
||||||
import de.grimsi.gameyfin.service.DownloadService;
|
import de.grimsi.gameyfin.service.DownloadService;
|
||||||
import de.grimsi.gameyfin.service.GameService;
|
import de.grimsi.gameyfin.service.GameService;
|
||||||
|
import de.grimsi.gameyfin.service.LibraryService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
@@ -23,6 +25,8 @@ public class LibraryManagementController {
|
|||||||
private final GameService gameService;
|
private final GameService gameService;
|
||||||
private final DownloadService downloadService;
|
private final DownloadService downloadService;
|
||||||
|
|
||||||
|
private final LibraryService libraryService;
|
||||||
|
|
||||||
@DeleteMapping(value = "/delete-game/{slug}", produces = MediaType.APPLICATION_JSON_VALUE)
|
@DeleteMapping(value = "/delete-game/{slug}", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public void deleteGame(@PathVariable String slug) {
|
public void deleteGame(@PathVariable String slug) {
|
||||||
gameService.deleteGame(slug);
|
gameService.deleteGame(slug);
|
||||||
@@ -53,4 +57,9 @@ public class LibraryManagementController {
|
|||||||
public List<UnmappableFile> getUnmappedFiles() {
|
public List<UnmappableFile> getUnmappedFiles() {
|
||||||
return gameService.getAllUnmappedFiles();
|
return gameService.getAllUnmappedFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping(value = "/autocomplete-suggestions", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public List<AutocompleteSuggestionDto> getAutocompleteSuggestions(@RequestParam String searchTerm, @RequestParam(required = false, defaultValue = "10") int limit) {
|
||||||
|
return libraryService.getAutocompleteSuggestions(searchTerm, limit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package de.grimsi.gameyfin.service;
|
package de.grimsi.gameyfin.service;
|
||||||
|
|
||||||
import com.igdb.proto.Igdb;
|
import com.igdb.proto.Igdb;
|
||||||
|
import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto;
|
||||||
import de.grimsi.gameyfin.entities.DetectedGame;
|
import de.grimsi.gameyfin.entities.DetectedGame;
|
||||||
import de.grimsi.gameyfin.entities.UnmappableFile;
|
import de.grimsi.gameyfin.entities.UnmappableFile;
|
||||||
import de.grimsi.gameyfin.igdb.IgdbWrapper;
|
import de.grimsi.gameyfin.igdb.IgdbWrapper;
|
||||||
import de.grimsi.gameyfin.mapper.GameMapper;
|
import de.grimsi.gameyfin.mapper.GameMapper;
|
||||||
import de.grimsi.gameyfin.repositories.DetectedGameRepository;
|
import de.grimsi.gameyfin.repositories.DetectedGameRepository;
|
||||||
import de.grimsi.gameyfin.repositories.UnmappableFileRepository;
|
import de.grimsi.gameyfin.repositories.UnmappableFileRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -24,21 +26,15 @@ import static de.grimsi.gameyfin.util.FilenameUtil.hasGameArchiveExtension;
|
|||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class LibraryService {
|
public class LibraryService {
|
||||||
|
|
||||||
@Value("${gameyfin.root}")
|
@Value("${gameyfin.root}")
|
||||||
private String rootFolderPath;
|
private String rootFolderPath;
|
||||||
|
|
||||||
@Value("${gameyfin.cache}")
|
private final IgdbWrapper igdbWrapper;
|
||||||
private String cacheFolderPath;
|
private final DetectedGameRepository detectedGameRepository;
|
||||||
@Autowired
|
private final UnmappableFileRepository unmappableFileRepository;
|
||||||
private IgdbWrapper igdbWrapper;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private DetectedGameRepository detectedGameRepository;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private UnmappableFileRepository unmappableFileRepository;
|
|
||||||
|
|
||||||
public List<Path> getGameFiles() {
|
public List<Path> getGameFiles() {
|
||||||
|
|
||||||
@@ -108,4 +104,8 @@ public class LibraryService {
|
|||||||
log.info("Scan finished in {} seconds: Found {} new games, deleted {} games, could not map {} files/folders, {} games total.",
|
log.info("Scan finished in {} seconds: Found {} new games, deleted {} games, could not map {} files/folders, {} games total.",
|
||||||
(int) stopWatch.getTotalTimeSeconds(), newDetectedGames.size(), deletedGames.size() + deletedUnmappableFiles.size(), newUnmappedFilesCounter.get(), detectedGameRepository.count());
|
(int) stopWatch.getTotalTimeSeconds(), newDetectedGames.size(), deletedGames.size() + deletedUnmappableFiles.size(), newUnmappedFilesCounter.get(), detectedGameRepository.count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<AutocompleteSuggestionDto> getAutocompleteSuggestions(String searchTerm, int limit) {
|
||||||
|
return igdbWrapper.findPossibleMatchingTitles(searchTerm, limit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
gameyfin:
|
gameyfin:
|
||||||
user: admin
|
user: admin
|
||||||
password: 112
|
password: password
|
||||||
#root: C:\Projects\privat\gameyfin-library
|
|
||||||
root: \\NAS-Simon\Öffentlich\Spiele
|
|
||||||
cache: ${gameyfin.root}\.gameyfin\cache
|
cache: ${gameyfin.root}\.gameyfin\cache
|
||||||
db: ${gameyfin.root}\.gameyfin\db
|
db: ${gameyfin.root}\.gameyfin\db
|
||||||
#db: ./data
|
|
||||||
igdb:
|
|
||||||
api:
|
|
||||||
client-id: 23l3l5qshx4dwjuao6yb8jyf1qrd08
|
|
||||||
client-secret: hf4iivmkzgne552j17p2d64xm03die
|
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
@@ -1,34 +1,3 @@
|
|||||||
server:
|
# General
|
||||||
port: 8080
|
logging.level:
|
||||||
error.include-stacktrace: never
|
root: info
|
||||||
|
|
||||||
spring:
|
|
||||||
mvc:
|
|
||||||
async.request-timeout: -1
|
|
||||||
jackson.default-property-inclusion: non_null
|
|
||||||
datasource.db-name: gameyfin_db
|
|
||||||
datasource.url: jdbc:h2:file:${gameyfin.db}/${spring.datasource.db-name};AUTO_SERVER=TRUE
|
|
||||||
datasource.username: gfadmin
|
|
||||||
datasource.password: gameyfin
|
|
||||||
datasource.driverClassName: org.h2.Driver
|
|
||||||
jpa:
|
|
||||||
database-platform: org.hibernate.dialect.H2Dialect
|
|
||||||
hibernate.ddl-auto: update
|
|
||||||
open-in-view: true
|
|
||||||
properties:
|
|
||||||
hibernate:
|
|
||||||
event.merge.entity_copy_observer: allow
|
|
||||||
|
|
||||||
gameyfin:
|
|
||||||
user: ""
|
|
||||||
password: ""
|
|
||||||
root: ""
|
|
||||||
cache: ${gameyfin.root}\.gameyfin\cache
|
|
||||||
db: ${gameyfin.root}\.gameyfin\db # Currently unused
|
|
||||||
file-extensions: iso, zip, rar, 7z, exe
|
|
||||||
igdb:
|
|
||||||
config:
|
|
||||||
preferred-platforms: 6
|
|
||||||
api:
|
|
||||||
client-id: ""
|
|
||||||
client-secret: ""
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
${AnsiColor.GREEN}
|
||||||
|
_____ ___ _
|
||||||
|
/ ___/ ___ _ __ _ ___ __ __ / _/ (_) ___
|
||||||
|
/ (_ / / _ `/ / ' \/ -_) / // / / _/ / / / _ \
|
||||||
|
\___/ \_,_/ /_/_/_/\__/ \_, / /_/ /_/ /_//_/
|
||||||
|
/___/
|
||||||
|
${AnsiColor.WHITE}
|
||||||
|
${application.name} ${application.version}
|
||||||
|
Powered by Spring Boot ${spring-boot.version}
|
||||||
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# This file contains properties related to the database configuration
|
||||||
|
#
|
||||||
|
#
|
||||||
|
spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true
|
||||||
|
spring.datasource.db-name=gameyfin_db
|
||||||
|
spring.datasource.url=jdbc:h2:file:${gameyfin.db}/${spring.datasource.db-name}
|
||||||
|
spring.datasource.username=gfadmin
|
||||||
|
spring.datasource.password=gameyfin
|
||||||
|
spring.datasource.driverClassName=org.h2.Driver
|
||||||
|
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
|
||||||
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
|
spring.jpa.open-in-view=true
|
||||||
|
spring.jpa.properties.hibernate.event.merge.entity_copy_observer=allow
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# This file contains properties related to the configuration of Gameyfin
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# Username and password for the web interface
|
||||||
|
gameyfin.user=
|
||||||
|
gameyfin.password=
|
||||||
|
|
||||||
|
# Root folder of your game library
|
||||||
|
gameyfin.root=
|
||||||
|
# Folders where gameyfin will store cached images and the database
|
||||||
|
gameyfin.cache=${gameyfin.root}\.gameyfin\cache
|
||||||
|
gameyfin.db=${gameyfin.root}\.gameyfin\db
|
||||||
|
|
||||||
|
# File extensions which gameyfin will recognize as game files
|
||||||
|
gameyfin.file-extensions=iso, zip, rar, 7z, exe
|
||||||
|
|
||||||
|
# List of IGDB platform enums to limit search results. FOr possible values see: https://api-docs.igdb.com/#platform
|
||||||
|
gameyfin.igdb.config.preferred-platforms=6
|
||||||
|
# Twitch Client ID and Client Secret
|
||||||
|
gameyfin.igdb.api.client-id=
|
||||||
|
gameyfin.igdb.api.client-secret=
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# This file contains properties that are *NOT* safe to override by the user
|
||||||
|
# In theory a user should not be able to override them since they will be loaded from the classpath at launch, overriding existing user properties with the same key
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# System Info
|
||||||
|
application.name=Gameyfin
|
||||||
|
application.version=@project.version@
|
||||||
|
# API
|
||||||
|
server.servlet.context-path=/
|
||||||
|
# Spring Actuator
|
||||||
|
management.endpoints.enabled-by-default=false
|
||||||
|
management.endpoint.health.enabled=true
|
||||||
|
# Server
|
||||||
|
server.error.include-stacktrace=never
|
||||||
|
spring.mvc.async.request-timeout=-1
|
||||||
|
# Jackson JSON Mapping
|
||||||
|
spring.jackson.default-property-inclusion=non_null
|
||||||
|
spring.jackson.mapper.accept-case-insensitive-enums=true
|
||||||
|
spring.jackson.deserialization.fail-on-unknown-properties=false
|
||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<artifactId>gameyfin</artifactId>
|
<artifactId>gameyfin</artifactId>
|
||||||
<groupId>de.grimsi</groupId>
|
<groupId>de.grimsi</groupId>
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
<version>1.0.0</version>
|
||||||
</parent>
|
</parent>
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {PathToSlugDto} from "../models/dtos/PathToSlugDto";
|
|||||||
import {Observable} from "rxjs";
|
import {Observable} from "rxjs";
|
||||||
import {DetectedGameDto} from "../models/dtos/DetectedGameDto";
|
import {DetectedGameDto} from "../models/dtos/DetectedGameDto";
|
||||||
import {UnmappedFileDto} from "../models/dtos/UnmappedFileDto";
|
import {UnmappedFileDto} from "../models/dtos/UnmappedFileDto";
|
||||||
|
import {AutocompleteSuggestionDto} from "../models/dtos/AutocompleteSuggestionDto";
|
||||||
|
|
||||||
export interface LibraryManagementApi {
|
export interface LibraryManagementApi {
|
||||||
mapGame(pathToSlugDto: PathToSlugDto): Observable<DetectedGameDto>;
|
mapGame(pathToSlugDto: PathToSlugDto): Observable<DetectedGameDto>;
|
||||||
@@ -9,4 +10,5 @@ export interface LibraryManagementApi {
|
|||||||
confirmGameMapping(slug: string, confirm: boolean): Observable<DetectedGameDto>;
|
confirmGameMapping(slug: string, confirm: boolean): Observable<DetectedGameDto>;
|
||||||
deleteGame(slug: string): Observable<Response>;
|
deleteGame(slug: string): Observable<Response>;
|
||||||
deleteUnmappedFile(id: number): Observable<Response>;
|
deleteUnmappedFile(id: number): Observable<Response>;
|
||||||
|
getAutocompleteSuggestions(searchTerm: string, limit: number): Observable<AutocompleteSuggestionDto[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Component } from '@angular/core';
|
import {Component} from '@angular/core';
|
||||||
import {ActivatedRoute, NavigationEnd, Router} from "@angular/router";
|
import {NavigationEnd, Router} from "@angular/router";
|
||||||
|
import {Config} from "./config/Config";
|
||||||
|
import {Title} from "@angular/platform-browser";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -7,16 +9,16 @@ import {ActivatedRoute, NavigationEnd, Router} from "@angular/router";
|
|||||||
styleUrls: ['./app.component.css']
|
styleUrls: ['./app.component.css']
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
title = 'frontend';
|
|
||||||
mySubscription;
|
|
||||||
|
|
||||||
constructor(private router: Router, private activatedRoute: ActivatedRoute){
|
constructor(private router: Router, private title: Title) {
|
||||||
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
|
||||||
this.mySubscription = this.router.events.subscribe((event) => {
|
this.router.events.subscribe((event) => {
|
||||||
if (event instanceof NavigationEnd) {
|
if (event instanceof NavigationEnd) {
|
||||||
// Trick the Router into believing it's last link wasn't previously loaded
|
// Trick the Router into believing it's last link wasn't previously loaded
|
||||||
this.router.navigated = false;
|
this.router.navigated = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
title.setTitle(Config.baseTitle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import { UnmappedFilesTableComponent } from './components/unmapped-files-table/u
|
|||||||
import {MatDividerModule} from "@angular/material/divider";
|
import {MatDividerModule} from "@angular/material/divider";
|
||||||
import {MatListModule} from "@angular/material/list";
|
import {MatListModule} from "@angular/material/list";
|
||||||
import {MatAutocompleteModule} from "@angular/material/autocomplete";
|
import {MatAutocompleteModule} from "@angular/material/autocomplete";
|
||||||
|
import { NgModelChangeDebouncedDirective } from './directives/ng-model-change-debounced.directive';
|
||||||
|
import { FooterComponent } from './components/footer/footer.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
@@ -63,7 +65,9 @@ import {MatAutocompleteModule} from "@angular/material/autocomplete";
|
|||||||
LibraryManagementComponent,
|
LibraryManagementComponent,
|
||||||
MapGameDialogComponent,
|
MapGameDialogComponent,
|
||||||
MappedGamesTableComponent,
|
MappedGamesTableComponent,
|
||||||
UnmappedFilesTableComponent
|
UnmappedFilesTableComponent,
|
||||||
|
NgModelChangeDebouncedDirective,
|
||||||
|
FooterComponent
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>© {{date| date:'yyyy'}} grimsi | <a href="{{githubUrl}}" target="_blank">GitHub</a></p>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@use 'sass:map';
|
||||||
|
@use '@angular/material' as mat;
|
||||||
|
@import '../../theme/default-theme';
|
||||||
|
|
||||||
|
a {
|
||||||
|
$config: mat.get-color-config($custom-theme);
|
||||||
|
$primary-palette: map.get($config, 'primary');
|
||||||
|
color: mat.get-color-from-palette($primary-palette, 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { FooterComponent } from './footer.component';
|
||||||
|
|
||||||
|
describe('FooterComponent', () => {
|
||||||
|
let component: FooterComponent;
|
||||||
|
let fixture: ComponentFixture<FooterComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ FooterComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(FooterComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-footer',
|
||||||
|
templateUrl: './footer.component.html',
|
||||||
|
styleUrls: ['./footer.component.scss']
|
||||||
|
})
|
||||||
|
export class FooterComponent implements OnInit {
|
||||||
|
|
||||||
|
githubUrl: string = "https://github.com/grimsi/gameyfin";
|
||||||
|
date: Date = new Date();
|
||||||
|
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<mat-toolbar style="position: sticky; top: 0; z-index: 99999">
|
<mat-toolbar>
|
||||||
<button mat-icon-button matTooltip="Home" (click)="goToLibraryScreen()" *ngIf="!onLibraryScreen()">
|
<button mat-icon-button matTooltip="Home" (click)="goToLibraryScreen()" *ngIf="!onLibraryScreen()">
|
||||||
<mat-icon>home</mat-icon>
|
<mat-icon>home</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,27 +1,3 @@
|
|||||||
.menu-item-icon {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
width: 45px;
|
|
||||||
padding-right: 30px;
|
|
||||||
padding-left: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drop-down {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mat-tab-nav-bar, .mat-tab-links, .mat-tab-link {
|
|
||||||
height: 64px;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacer {
|
.spacer {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#username {
|
|
||||||
margin-right: 10px;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,7 +53,12 @@ export class HeaderComponent {
|
|||||||
|
|
||||||
scanLibrary(): void {
|
scanLibrary(): void {
|
||||||
this.libraryService.scanLibrary().pipe(timeInterval()).subscribe({
|
this.libraryService.scanLibrary().pipe(timeInterval()).subscribe({
|
||||||
next: value => this.snackBar.open(`Library scan completed in ${Math.trunc(value.interval / 1000)} seconds.`, undefined, {duration: 2000}),
|
next: value => {
|
||||||
|
// Refresh the current page "angular style"
|
||||||
|
this.router.navigate([this.router.url]).then(() =>
|
||||||
|
this.snackBar.open(`Library scan completed in ${Math.trunc(value.interval / 1000)} seconds.`, undefined, {duration: 5000})
|
||||||
|
)
|
||||||
|
},
|
||||||
error: error => this.snackBar.open(`Error while scanning library: ${error.error.message}`, undefined, {duration: 5000})
|
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})
|
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})
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="6px">
|
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="6px">
|
||||||
<mat-icon matTooltip="Search for games by title">search</mat-icon>
|
<mat-icon matTooltip="Search for games by title">search</mat-icon>
|
||||||
<mat-form-field fxFlex="80" class="filter-category-content">
|
<mat-form-field fxFlex="80" class="filter-category-content">
|
||||||
<input type="text" matInput [matAutocomplete]="auto" [(ngModel)]="searchTerm" (ngModelChange)="filterGames()">
|
<input type="text" matInput [matAutocomplete]="librarySearchAutocomplete" [(ngModel)]="searchTerm" (ngModelChange)="filterGames()">
|
||||||
<mat-autocomplete #auto="matAutocomplete">
|
<mat-autocomplete #librarySearchAutocomplete="matAutocomplete">
|
||||||
<mat-option *ngFor="let game of games" [value]="game.title">
|
<mat-option *ngFor="let game of games" [value]="game.title">
|
||||||
{{game.title}}
|
{{game.title}}
|
||||||
</mat-option>
|
</mat-option>
|
||||||
|
|||||||
@@ -3,13 +3,28 @@
|
|||||||
<form fxLayout="column" fxLayoutAlign="space-evenly stretch">
|
<form fxLayout="column" fxLayoutAlign="space-evenly stretch">
|
||||||
|
|
||||||
<p>Path: {{path}}</p>
|
<p>Path: {{path}}</p>
|
||||||
|
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<input matInput type="text" placeholder="IGDB Slug" [formControl]="newSlugInput" [value]="currentSlug"/>
|
<div fxLayout="row">
|
||||||
|
<input type="text" placeholder="IGDB Slug" matInput [matAutocomplete]="igdbSlugAutocomplete" [(ngModel)]="slug" (ngModelChangeDebounced)="loadSuggestions()" [ngModelOptions]="{standalone: true}">
|
||||||
|
<mat-spinner *ngIf="suggestionsLoading" [diameter]="16"></mat-spinner>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mat-autocomplete #igdbSlugAutocomplete="matAutocomplete">
|
||||||
|
<mat-option *ngFor="let suggestion of autocompleteSuggestions" [value]="suggestion.slug">
|
||||||
|
{{suggestion.title}} ({{getFullYearFromTimestamp(suggestion.releaseDate)}})
|
||||||
|
</mat-option>
|
||||||
|
</mat-autocomplete>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</mat-dialog-content>
|
</mat-dialog-content>
|
||||||
<mat-dialog-actions align="end">
|
<mat-dialog-actions align="end">
|
||||||
<button mat-raised-button [mat-dialog-close]="false" color="accent">Cancel</button>
|
<button mat-raised-button [mat-dialog-close]="false" color="accent" [disabled]="submitLoading">Cancel</button>
|
||||||
<button mat-raised-button (click)="submit()" [disabled]="newSlugInput?.value?.length < 1" color="primary">OK</button>
|
<button mat-raised-button (click)="submit()" [disabled]="slug.length < 1 || submitLoading" color="primary">
|
||||||
|
<span *ngIf="!submitLoading">OK</span>
|
||||||
|
<div *ngIf="submitLoading" fxLayout="column" fxLayoutAlign="center center" style="height: 36px;">
|
||||||
|
<mat-spinner [diameter]="24"></mat-spinner>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {Component, Inject, OnInit} from '@angular/core';
|
import {Component, Inject, OnInit} from '@angular/core';
|
||||||
import {FormBuilder, FormControl} from "@angular/forms";
|
|
||||||
import {LibraryManagementService} from "../../services/library-management.service";
|
import {LibraryManagementService} from "../../services/library-management.service";
|
||||||
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
|
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
|
||||||
import {PathToSlugDto} from "../../models/dtos/PathToSlugDto";
|
import {PathToSlugDto} from "../../models/dtos/PathToSlugDto";
|
||||||
|
import {DialogService} from "../../services/dialog.service";
|
||||||
|
import {ApiErrorResponse} from "../../models/dtos/ApiErrorResponse";
|
||||||
|
import {AutocompleteSuggestionDto} from "../../models/dtos/AutocompleteSuggestionDto";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-map-game-dialog',
|
selector: 'app-map-game-dialog',
|
||||||
@@ -12,26 +14,71 @@ import {PathToSlugDto} from "../../models/dtos/PathToSlugDto";
|
|||||||
export class MapGameDialogComponent implements OnInit {
|
export class MapGameDialogComponent implements OnInit {
|
||||||
|
|
||||||
path: string;
|
path: string;
|
||||||
currentSlug?: string;
|
slug: string;
|
||||||
newSlugInput: FormControl;
|
|
||||||
|
|
||||||
constructor(private fb: FormBuilder,
|
autocompleteSuggestions: AutocompleteSuggestionDto[] = [];
|
||||||
private libraryManagementService: LibraryManagementService,
|
|
||||||
|
submitLoading: boolean = false;
|
||||||
|
suggestionsLoading: boolean = false;
|
||||||
|
|
||||||
|
constructor(private libraryManagementService: LibraryManagementService,
|
||||||
|
private dialogService: DialogService,
|
||||||
public dialogRef: MatDialogRef<MapGameDialogComponent>,
|
public dialogRef: MatDialogRef<MapGameDialogComponent>,
|
||||||
@Inject(MAT_DIALOG_DATA) data: any) {
|
@Inject(MAT_DIALOG_DATA) data: any) {
|
||||||
this.path = data.path;
|
this.path = data.path;
|
||||||
this.currentSlug = data.slug;
|
this.slug = data.slug ?? '';
|
||||||
this.newSlugInput = new FormControl(this.currentSlug);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
this.loadInitialSuggestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
this.libraryManagementService.mapGame(new PathToSlugDto(this.newSlugInput.value, this.path)).subscribe({
|
this.submitLoading = true;
|
||||||
|
this.libraryManagementService.mapGame(new PathToSlugDto(this.slug, this.path)).subscribe({
|
||||||
next: () => this.dialogRef.close(true),
|
next: () => this.dialogRef.close(true),
|
||||||
error: () => this.dialogRef.close(false)
|
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 extractedTitleFromPath: string = this.path.match(/([^\\/]*)[\\/]*$/)![1];
|
||||||
|
// Match it until the first special characters
|
||||||
|
extractedTitleFromPath = extractedTitleFromPath.match(/^[a-zA-Z0-9:\- ]+/)![0];
|
||||||
|
|
||||||
|
if(extractedTitleFromPath == null) {
|
||||||
|
this.suggestionsLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.libraryManagementService.getAutocompleteSuggestions(extractedTitleFromPath, 10).subscribe({
|
||||||
|
next: suggestions => {
|
||||||
|
this.autocompleteSuggestions = suggestions;
|
||||||
|
this.suggestionsLoading = false;
|
||||||
|
},
|
||||||
|
error: () => this.suggestionsLoading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSuggestions(): void {
|
||||||
|
this.suggestionsLoading = true;
|
||||||
|
this.libraryManagementService.getAutocompleteSuggestions(this.slug, 50).subscribe({
|
||||||
|
next: suggestions => {
|
||||||
|
this.autocompleteSuggestions = suggestions;
|
||||||
|
this.suggestionsLoading = false;
|
||||||
|
},
|
||||||
|
error: () => this.suggestionsLoading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getFullYearFromTimestamp(timestamp: number): number {
|
||||||
|
return new Date(timestamp).getFullYear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ export class MappedGamesTableComponent implements AfterViewInit, OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openCorrectMappingDialog(mappedGame: DetectedGameDto): void {
|
openCorrectMappingDialog(mappedGame: DetectedGameDto): void {
|
||||||
this.dialogService.correctGameMappingDialog(mappedGame);
|
this.dialogService.correctGameMappingDialog(mappedGame).subscribe(gameSuccessfullyMapped => {
|
||||||
|
if (gameSuccessfullyMapped) this.refreshMappedGamesList();
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private refreshData(newData: DetectedGameDto[]): void {
|
private refreshData(newData: DetectedGameDto[]): void {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export class Config {
|
export class Config {
|
||||||
public static baseTitle = 'Game-Radar';
|
public static baseTitle = 'Gameyfin';
|
||||||
public static apiBasePath = '/v1';
|
public static apiBasePath = '/v1';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NgModelChangeDebouncedDirective } from './ng-model-change-debounced.directive';
|
||||||
|
|
||||||
|
describe('NgModelChangeDebouncedDirective', () => {
|
||||||
|
it('should create an instance', () => {
|
||||||
|
const directive = new NgModelChangeDebouncedDirective();
|
||||||
|
expect(directive).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {Directive, EventEmitter, Input, OnDestroy, Output} from "@angular/core";
|
||||||
|
import {debounceTime, distinctUntilChanged, skip, Subscription} from "rxjs";
|
||||||
|
import {NgModel} from "@angular/forms";
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[ngModelChangeDebounced]',
|
||||||
|
})
|
||||||
|
export class NgModelChangeDebouncedDirective implements OnDestroy {
|
||||||
|
@Output()
|
||||||
|
ngModelChangeDebounced = new EventEmitter<any>();
|
||||||
|
@Input()
|
||||||
|
ngModelChangeDebounceTime = 500; // optional, 500 default
|
||||||
|
|
||||||
|
subscription: Subscription;
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(private ngModel: NgModel) {
|
||||||
|
this.subscription = this.ngModel.control.valueChanges.pipe(
|
||||||
|
skip(1), // skip initial value
|
||||||
|
distinctUntilChanged(),
|
||||||
|
debounceTime(this.ngModelChangeDebounceTime)
|
||||||
|
).subscribe((value) => this.ngModelChangeDebounced.emit(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,23 @@ import {Component, OnInit} from '@angular/core';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-navbar-layout',
|
selector: 'app-navbar-layout',
|
||||||
template: `
|
template: `
|
||||||
<div fxFlexFill>
|
<div class="main-container" fxLayout="column">
|
||||||
|
<div fxFlex="none" style="position: sticky; top: 0; z-index: 99999">
|
||||||
<app-header></app-header>
|
<app-header></app-header>
|
||||||
<div fxLayout="column" fxLayoutAlign="space-around stretch">
|
|
||||||
<div fxFlex>
|
|
||||||
<router-outlet class="hidden-router"></router-outlet>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div fxFlex>
|
||||||
|
<router-outlet></router-outlet><!-- class="hidden-router" -->
|
||||||
|
</div>
|
||||||
|
<div fxLayout="row" fxLayoutAlign="center center">
|
||||||
|
<app-footer></app-footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
styles: []
|
styles: [`
|
||||||
|
.main-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
`]
|
||||||
})
|
})
|
||||||
export class NavbarLayoutComponent implements OnInit {
|
export class NavbarLayoutComponent implements OnInit {
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class AutocompleteSuggestionDto {
|
||||||
|
slug!: string;
|
||||||
|
title!: string;
|
||||||
|
releaseDate!: number;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {PathToSlugDto} from "../models/dtos/PathToSlugDto";
|
|||||||
import {UnmappedFileDto} from "../models/dtos/UnmappedFileDto";
|
import {UnmappedFileDto} from "../models/dtos/UnmappedFileDto";
|
||||||
import {LibraryManagementApi} from "../api/LibraryManagementApi";
|
import {LibraryManagementApi} from "../api/LibraryManagementApi";
|
||||||
import {GamesService} from "./games.service";
|
import {GamesService} from "./games.service";
|
||||||
|
import {AutocompleteSuggestionDto} from "../models/dtos/AutocompleteSuggestionDto";
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -42,4 +43,11 @@ export class LibraryManagementService implements LibraryManagementApi {
|
|||||||
return this.http.delete<Response>(`${this.apiPath}/delete-unmapped-file/${id}`);
|
return this.http.delete<Response>(`${this.apiPath}/delete-unmapped-file/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAutocompleteSuggestions(searchTerm: string, limit: number): Observable<AutocompleteSuggestionDto[]> {
|
||||||
|
let queryParams = new HttpParams();
|
||||||
|
queryParams = queryParams.append("searchTerm", searchTerm);
|
||||||
|
queryParams = queryParams.append("limit", limit);
|
||||||
|
|
||||||
|
return this.http.get<AutocompleteSuggestionDto[]>(`${this.apiPath}/autocomplete-suggestions`, {params:queryParams})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
"enableI18nLegacyMessageIdFormat": false,
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
"strictInjectionParameters": true,
|
"strictInjectionParameters": true,
|
||||||
"strictInputAccessModifiers": true,
|
"strictInputAccessModifiers": true,
|
||||||
"strictTemplates": true
|
"strictTemplates": true,
|
||||||
|
"extendedDiagnostics": {
|
||||||
|
"checks": {
|
||||||
|
// Currently buggy, see https://github.com/angular/angular/issues/46918
|
||||||
|
"optionalChainNotNullable": "suppress"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<groupId>de.grimsi</groupId>
|
<groupId>de.grimsi</groupId>
|
||||||
<artifactId>gameyfin</artifactId>
|
<artifactId>gameyfin</artifactId>
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
<version>1.0.0</version>
|
||||||
<name>gameyfin</name>
|
<name>gameyfin</name>
|
||||||
<description>gameyfin</description>
|
<description>gameyfin</description>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user