diff --git a/backend/pom.xml b/backend/pom.xml index 69d8c28..8b471f9 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -7,7 +7,7 @@ gameyfin de.grimsi - 0.0.1-SNAPSHOT + 1.0.0 backend @@ -15,13 +15,11 @@ 18 1.6.9 - 1.7.1 + 1.7.1 2.11.0 - 3.11.4 - 3.21.2 - 0.0.1-SNAPSHOT 1.21 - 4.0.0 + 3.11.4 + 3.21.3 @@ -50,17 +48,17 @@ io.github.resilience4j resilience4j-reactor - ${resilience4j-reactor.version} + ${resilience4j.version} io.github.resilience4j resilience4j-ratelimiter - ${resilience4j-reactor.version} + ${resilience4j.version} io.github.resilience4j resilience4j-bulkhead - ${resilience4j-reactor.version} + ${resilience4j.version} @@ -123,6 +121,21 @@ + + + ${basedir}/src/main/resources + true + + **/*.properties + **/*.yml + **/*.yaml + **/*.txt + **/*.js + **/*.css + **/*.html + + + org.springframework.boot diff --git a/backend/src/main/java/de/grimsi/gameyfin/config/SecureProperties.java b/backend/src/main/java/de/grimsi/gameyfin/config/SecureProperties.java new file mode 100644 index 0000000..6d66527 --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/config/SecureProperties.java @@ -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); + } + } +} diff --git a/backend/src/main/java/de/grimsi/gameyfin/dto/AutocompleteSuggestionDto.java b/backend/src/main/java/de/grimsi/gameyfin/dto/AutocompleteSuggestionDto.java new file mode 100644 index 0000000..5bfcd06 --- /dev/null +++ b/backend/src/main/java/de/grimsi/gameyfin/dto/AutocompleteSuggestionDto.java @@ -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; +} 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 1072dec..61868f0 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java +++ b/backend/src/main/java/de/grimsi/gameyfin/igdb/IgdbWrapper.java @@ -2,7 +2,9 @@ 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.igdb.dto.TwitchOAuthTokenDto; +import de.grimsi.gameyfin.mapper.GameMapper; import io.github.resilience4j.reactor.bulkhead.operator.BulkheadOperator; import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator; import lombok.extern.slf4j.Slf4j; @@ -15,6 +17,7 @@ import org.springframework.web.util.UriComponentsBuilder; import javax.annotation.PostConstruct; import java.net.URI; +import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.regex.Matcher; @@ -91,6 +94,18 @@ public class IgdbWrapper { return Optional.of(gameResult.getGames(0)); } + public List 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 searchForGameByTitle(String searchTerm) { Igdb.GameResult gameResult = queryIgdbApi( IgdbApiProperties.ENPOINT_GAMES_PROTOBUF, 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 b1a9b20..bc9fe7b 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/mapper/GameMapper.java +++ b/backend/src/main/java/de/grimsi/gameyfin/mapper/GameMapper.java @@ -1,6 +1,7 @@ package de.grimsi.gameyfin.mapper; 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.service.LibraryService; @@ -60,6 +61,14 @@ public class GameMapper { .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) { String coverId = g.getCover().getImageId(); 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 46b2081..8e3090b 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryController.java +++ b/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryController.java @@ -3,6 +3,7 @@ package de.grimsi.gameyfin.rest; import de.grimsi.gameyfin.service.DownloadService; import de.grimsi.gameyfin.service.LibraryService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -20,6 +21,7 @@ import java.util.List; @RequestMapping("/v1/library") @PreAuthorize("hasAuthority('ADMIN_API_ACCESS')") @RequiredArgsConstructor +@Slf4j public class LibraryController { private final LibraryService libraryService; @@ -37,6 +39,8 @@ public class LibraryController { downloadService.downloadGameCoversFromIgdb(); downloadService.downloadGameScreenshotsFromIgdb(); downloadService.downloadCompanyLogosFromIgdb(); + + log.info("Downloading images completed."); } @GetMapping(value = "/files", produces = MediaType.APPLICATION_JSON_VALUE) 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 1f1964b..d5d4ce6 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryManagementController.java +++ b/backend/src/main/java/de/grimsi/gameyfin/rest/LibraryManagementController.java @@ -1,12 +1,14 @@ 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.UnmappableFile; import de.grimsi.gameyfin.repositories.DetectedGameRepository; import de.grimsi.gameyfin.service.DownloadService; import de.grimsi.gameyfin.service.GameService; +import de.grimsi.gameyfin.service.LibraryService; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.security.access.prepost.PreAuthorize; @@ -23,6 +25,8 @@ public class LibraryManagementController { private final GameService gameService; private final DownloadService downloadService; + private final LibraryService libraryService; + @DeleteMapping(value = "/delete-game/{slug}", produces = MediaType.APPLICATION_JSON_VALUE) public void deleteGame(@PathVariable String slug) { gameService.deleteGame(slug); @@ -53,4 +57,9 @@ public class LibraryManagementController { public List getUnmappedFiles() { return gameService.getAllUnmappedFiles(); } + + @GetMapping(value = "/autocomplete-suggestions", produces = MediaType.APPLICATION_JSON_VALUE) + public List getAutocompleteSuggestions(@RequestParam String searchTerm, @RequestParam(required = false, defaultValue = "10") int limit) { + return libraryService.getAutocompleteSuggestions(searchTerm, limit); + } } 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 cd0d8d1..3531176 100644 --- a/backend/src/main/java/de/grimsi/gameyfin/service/LibraryService.java +++ b/backend/src/main/java/de/grimsi/gameyfin/service/LibraryService.java @@ -1,12 +1,14 @@ package de.grimsi.gameyfin.service; import com.igdb.proto.Igdb; +import de.grimsi.gameyfin.dto.AutocompleteSuggestionDto; import de.grimsi.gameyfin.entities.DetectedGame; 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.UnmappableFileRepository; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -24,21 +26,15 @@ import static de.grimsi.gameyfin.util.FilenameUtil.hasGameArchiveExtension; @Slf4j @Service +@RequiredArgsConstructor public class LibraryService { @Value("${gameyfin.root}") private String rootFolderPath; - @Value("${gameyfin.cache}") - private String cacheFolderPath; - @Autowired - private IgdbWrapper igdbWrapper; - - @Autowired - private DetectedGameRepository detectedGameRepository; - - @Autowired - private UnmappableFileRepository unmappableFileRepository; + private final IgdbWrapper igdbWrapper; + private final DetectedGameRepository detectedGameRepository; + private final UnmappableFileRepository unmappableFileRepository; public List 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.", (int) stopWatch.getTotalTimeSeconds(), newDetectedGames.size(), deletedGames.size() + deletedUnmappableFiles.size(), newUnmappedFilesCounter.get(), detectedGameRepository.count()); } + + public List getAutocompleteSuggestions(String searchTerm, int limit) { + return igdbWrapper.findPossibleMatchingTitles(searchTerm, limit); + } } diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index e7120b7..30efaba 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -1,15 +1,8 @@ gameyfin: user: admin - password: 112 - #root: C:\Projects\privat\gameyfin-library - root: \\NAS-Simon\Öffentlich\Spiele + password: password cache: ${gameyfin.root}\.gameyfin\cache db: ${gameyfin.root}\.gameyfin\db - #db: ./data - igdb: - api: - client-id: 23l3l5qshx4dwjuao6yb8jyf1qrd08 - client-secret: hf4iivmkzgne552j17p2d64xm03die logging: level: diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index a4ff3e4..bf05140 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,34 +1,3 @@ -server: - port: 8080 - error.include-stacktrace: never - -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: "" \ No newline at end of file +# General +logging.level: + root: info \ No newline at end of file diff --git a/backend/src/main/resources/banner.txt b/backend/src/main/resources/banner.txt new file mode 100644 index 0000000..c29a8a8 --- /dev/null +++ b/backend/src/main/resources/banner.txt @@ -0,0 +1,10 @@ +${AnsiColor.GREEN} + _____ ___ _ + / ___/ ___ _ __ _ ___ __ __ / _/ (_) ___ +/ (_ / / _ `/ / ' \/ -_) / // / / _/ / / / _ \ +\___/ \_,_/ /_/_/_/\__/ \_, / /_/ /_/ /_//_/ + /___/ +${AnsiColor.WHITE} +${application.name} ${application.version} +Powered by Spring Boot ${spring-boot.version} + diff --git a/backend/src/main/resources/config/database.properties b/backend/src/main/resources/config/database.properties new file mode 100644 index 0000000..6123b15 --- /dev/null +++ b/backend/src/main/resources/config/database.properties @@ -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 \ No newline at end of file diff --git a/backend/src/main/resources/config/gameyfin.properties b/backend/src/main/resources/config/gameyfin.properties new file mode 100644 index 0000000..40fd1d7 --- /dev/null +++ b/backend/src/main/resources/config/gameyfin.properties @@ -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= \ No newline at end of file diff --git a/backend/src/main/resources/config/secure.properties b/backend/src/main/resources/config/secure.properties new file mode 100644 index 0000000..cda7c4e --- /dev/null +++ b/backend/src/main/resources/config/secure.properties @@ -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 diff --git a/frontend/pom.xml b/frontend/pom.xml index 722f307..6d5dce8 100644 --- a/frontend/pom.xml +++ b/frontend/pom.xml @@ -5,7 +5,7 @@ gameyfin de.grimsi - 0.0.1-SNAPSHOT + 1.0.0 4.0.0 diff --git a/frontend/src/app/api/LibraryManagementApi.ts b/frontend/src/app/api/LibraryManagementApi.ts index bb4b8b1..26a185e 100644 --- a/frontend/src/app/api/LibraryManagementApi.ts +++ b/frontend/src/app/api/LibraryManagementApi.ts @@ -2,6 +2,7 @@ import {PathToSlugDto} from "../models/dtos/PathToSlugDto"; import {Observable} from "rxjs"; import {DetectedGameDto} from "../models/dtos/DetectedGameDto"; import {UnmappedFileDto} from "../models/dtos/UnmappedFileDto"; +import {AutocompleteSuggestionDto} from "../models/dtos/AutocompleteSuggestionDto"; export interface LibraryManagementApi { mapGame(pathToSlugDto: PathToSlugDto): Observable; @@ -9,4 +10,5 @@ export interface LibraryManagementApi { confirmGameMapping(slug: string, confirm: boolean): Observable; deleteGame(slug: string): Observable; deleteUnmappedFile(id: number): Observable; + getAutocompleteSuggestions(searchTerm: string, limit: number): Observable; } diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 5b8f69f..67759cf 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -1,5 +1,7 @@ -import { Component } from '@angular/core'; -import {ActivatedRoute, NavigationEnd, Router} from "@angular/router"; +import {Component} from '@angular/core'; +import {NavigationEnd, Router} from "@angular/router"; +import {Config} from "./config/Config"; +import {Title} from "@angular/platform-browser"; @Component({ selector: 'app-root', @@ -7,16 +9,16 @@ import {ActivatedRoute, NavigationEnd, Router} from "@angular/router"; styleUrls: ['./app.component.css'] }) 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.mySubscription = this.router.events.subscribe((event) => { + this.router.events.subscribe((event) => { if (event instanceof NavigationEnd) { // Trick the Router into believing it's last link wasn't previously loaded this.router.navigated = false; } }); + + title.setTitle(Config.baseTitle); } } diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index c35ba06..ac2dae5 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -47,6 +47,8 @@ import { UnmappedFilesTableComponent } from './components/unmapped-files-table/u import {MatDividerModule} from "@angular/material/divider"; import {MatListModule} from "@angular/material/list"; import {MatAutocompleteModule} from "@angular/material/autocomplete"; +import { NgModelChangeDebouncedDirective } from './directives/ng-model-change-debounced.directive'; +import { FooterComponent } from './components/footer/footer.component'; @NgModule({ declarations: [ @@ -63,7 +65,9 @@ import {MatAutocompleteModule} from "@angular/material/autocomplete"; LibraryManagementComponent, MapGameDialogComponent, MappedGamesTableComponent, - UnmappedFilesTableComponent + UnmappedFilesTableComponent, + NgModelChangeDebouncedDirective, + FooterComponent ], imports: [ BrowserModule, diff --git a/frontend/src/app/components/footer/footer.component.html b/frontend/src/app/components/footer/footer.component.html new file mode 100644 index 0000000..fd02256 --- /dev/null +++ b/frontend/src/app/components/footer/footer.component.html @@ -0,0 +1 @@ +

© {{date| date:'yyyy'}} grimsi | GitHub

diff --git a/frontend/src/app/components/footer/footer.component.scss b/frontend/src/app/components/footer/footer.component.scss new file mode 100644 index 0000000..b7d02fb --- /dev/null +++ b/frontend/src/app/components/footer/footer.component.scss @@ -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); +} diff --git a/frontend/src/app/components/footer/footer.component.spec.ts b/frontend/src/app/components/footer/footer.component.spec.ts new file mode 100644 index 0000000..953b22c --- /dev/null +++ b/frontend/src/app/components/footer/footer.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FooterComponent } from './footer.component'; + +describe('FooterComponent', () => { + let component: FooterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ FooterComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(FooterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/footer/footer.component.ts b/frontend/src/app/components/footer/footer.component.ts new file mode 100644 index 0000000..abf206d --- /dev/null +++ b/frontend/src/app/components/footer/footer.component.ts @@ -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 { + } + +} diff --git a/frontend/src/app/components/header/header.component.html b/frontend/src/app/components/header/header.component.html index b10827e..5744633 100644 --- a/frontend/src/app/components/header/header.component.html +++ b/frontend/src/app/components/header/header.component.html @@ -1,4 +1,4 @@ - + diff --git a/frontend/src/app/components/header/header.component.scss b/frontend/src/app/components/header/header.component.scss index 0442de3..d0ca4cc 100644 --- a/frontend/src/app/components/header/header.component.scss +++ b/frontend/src/app/components/header/header.component.scss @@ -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 { flex: 1 1 auto; } - -#username { - margin-right: 10px; - user-select: none; -} diff --git a/frontend/src/app/components/header/header.component.ts b/frontend/src/app/components/header/header.component.ts index d084105..81919b9 100644 --- a/frontend/src/app/components/header/header.component.ts +++ b/frontend/src/app/components/header/header.component.ts @@ -53,7 +53,12 @@ export class HeaderComponent { scanLibrary(): void { 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}) }) 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}) 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 274c041..173a6ef 100644 --- a/frontend/src/app/components/library-overview/library-overview.component.html +++ b/frontend/src/app/components/library-overview/library-overview.component.html @@ -28,8 +28,8 @@
search - - + + {{game.title}} 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 eb9b888..dac89ed 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 @@ -3,13 +3,28 @@

Path: {{path}}

+ - +
+ + +
+ + + + {{suggestion.title}} ({{getFullYearFromTimestamp(suggestion.releaseDate)}}) + +
- - + + diff --git a/frontend/src/app/components/map-game-dialog/map-game-dialog.component.ts b/frontend/src/app/components/map-game-dialog/map-game-dialog.component.ts index 49d446b..ecca37d 100644 --- a/frontend/src/app/components/map-game-dialog/map-game-dialog.component.ts +++ b/frontend/src/app/components/map-game-dialog/map-game-dialog.component.ts @@ -1,8 +1,10 @@ import {Component, Inject, OnInit} from '@angular/core'; -import {FormBuilder, FormControl} from "@angular/forms"; 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 {AutocompleteSuggestionDto} from "../../models/dtos/AutocompleteSuggestionDto"; @Component({ selector: 'app-map-game-dialog', @@ -12,26 +14,71 @@ import {PathToSlugDto} from "../../models/dtos/PathToSlugDto"; export class MapGameDialogComponent implements OnInit { path: string; - currentSlug?: string; - newSlugInput: FormControl; + slug: string; - constructor(private fb: FormBuilder, - private libraryManagementService: LibraryManagementService, + autocompleteSuggestions: AutocompleteSuggestionDto[] = []; + + 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.currentSlug = data.slug; - this.newSlugInput = new FormControl(this.currentSlug); + this.slug = data.slug ?? ''; } ngOnInit() { + this.loadInitialSuggestions(); } 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), - 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(); + } } diff --git a/frontend/src/app/components/mapped-games-table/mapped-games-table.component.ts b/frontend/src/app/components/mapped-games-table/mapped-games-table.component.ts index 889e770..8407f8f 100644 --- a/frontend/src/app/components/mapped-games-table/mapped-games-table.component.ts +++ b/frontend/src/app/components/mapped-games-table/mapped-games-table.component.ts @@ -74,7 +74,9 @@ export class MappedGamesTableComponent implements AfterViewInit, OnChanges { } openCorrectMappingDialog(mappedGame: DetectedGameDto): void { - this.dialogService.correctGameMappingDialog(mappedGame); + this.dialogService.correctGameMappingDialog(mappedGame).subscribe(gameSuccessfullyMapped => { + if (gameSuccessfullyMapped) this.refreshMappedGamesList(); + }) } private refreshData(newData: DetectedGameDto[]): void { diff --git a/frontend/src/app/config/Config.ts b/frontend/src/app/config/Config.ts index b59ff6d..060b726 100644 --- a/frontend/src/app/config/Config.ts +++ b/frontend/src/app/config/Config.ts @@ -1,4 +1,4 @@ export class Config { - public static baseTitle = 'Game-Radar'; + public static baseTitle = 'Gameyfin'; public static apiBasePath = '/v1'; } diff --git a/frontend/src/app/directives/ng-model-change-debounced.directive.spec.ts b/frontend/src/app/directives/ng-model-change-debounced.directive.spec.ts new file mode 100644 index 0000000..50d8e58 --- /dev/null +++ b/frontend/src/app/directives/ng-model-change-debounced.directive.spec.ts @@ -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(); + }); +}); diff --git a/frontend/src/app/directives/ng-model-change-debounced.directive.ts b/frontend/src/app/directives/ng-model-change-debounced.directive.ts new file mode 100644 index 0000000..fb4b448 --- /dev/null +++ b/frontend/src/app/directives/ng-model-change-debounced.directive.ts @@ -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(); + @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)); + } +} diff --git a/frontend/src/app/layouts/navbar-layout/navbar-layout.component.ts b/frontend/src/app/layouts/navbar-layout/navbar-layout.component.ts index 4a6e8ec..9ce31f1 100644 --- a/frontend/src/app/layouts/navbar-layout/navbar-layout.component.ts +++ b/frontend/src/app/layouts/navbar-layout/navbar-layout.component.ts @@ -3,16 +3,23 @@ import {Component, OnInit} from '@angular/core'; @Component({ selector: 'app-navbar-layout', template: ` -
- -
-
- -
+
+
+ +
+
+ +
+
+
`, - styles: [] + styles: [` + .main-container { + min-height: 100vh; + } + `] }) export class NavbarLayoutComponent implements OnInit { diff --git a/frontend/src/app/models/dtos/AutocompleteSuggestionDto.ts b/frontend/src/app/models/dtos/AutocompleteSuggestionDto.ts new file mode 100644 index 0000000..f4386a2 --- /dev/null +++ b/frontend/src/app/models/dtos/AutocompleteSuggestionDto.ts @@ -0,0 +1,5 @@ +export class AutocompleteSuggestionDto { + slug!: string; + title!: string; + releaseDate!: number; +} diff --git a/frontend/src/app/services/library-management.service.ts b/frontend/src/app/services/library-management.service.ts index d1dfb37..d36eb4c 100644 --- a/frontend/src/app/services/library-management.service.ts +++ b/frontend/src/app/services/library-management.service.ts @@ -6,6 +6,7 @@ import {PathToSlugDto} from "../models/dtos/PathToSlugDto"; import {UnmappedFileDto} from "../models/dtos/UnmappedFileDto"; import {LibraryManagementApi} from "../api/LibraryManagementApi"; import {GamesService} from "./games.service"; +import {AutocompleteSuggestionDto} from "../models/dtos/AutocompleteSuggestionDto"; @Injectable({ providedIn: 'root' @@ -42,4 +43,11 @@ export class LibraryManagementService implements LibraryManagementApi { return this.http.delete(`${this.apiPath}/delete-unmapped-file/${id}`); } + getAutocompleteSuggestions(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}/autocomplete-suggestions`, {params:queryParams}) + } } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 1c3f1f4..03fbae1 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -29,6 +29,12 @@ "enableI18nLegacyMessageIdFormat": false, "strictInjectionParameters": true, "strictInputAccessModifiers": true, - "strictTemplates": true + "strictTemplates": true, + "extendedDiagnostics": { + "checks": { + // Currently buggy, see https://github.com/angular/angular/issues/46918 + "optionalChainNotNullable": "suppress" + } + } } } diff --git a/pom.xml b/pom.xml index 3d8f88e..66b415a 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ de.grimsi gameyfin - 0.0.1-SNAPSHOT + 1.0.0 gameyfin gameyfin