feat(platforms): added platform support (#67)

Now libraries can be assigned to platforms in the admin section.
Games will be assigned to libraries on scanning.

Resolves grimsi/gameyfin#31

Co-authored-by: shawly <shawlyde@gmail.com>
This commit is contained in:
Simon
2022-10-25 21:55:35 +03:00
committed by GitHub
parent 7504cd3500
commit 8e23549336
54 changed files with 1426 additions and 113 deletions
+2 -1
View File
@@ -1,9 +1,10 @@
import {Observable} from "rxjs";
import {LibraryScanResultDto} from "../models/dtos/LibraryScanResultDto";
import {ImageDownloadResultDto} from "../models/dtos/ImageDownloadResultDto";
import {LibraryScanRequestDto} from "../models/dtos/LibraryScanRequestDto";
export interface LibraryApi {
scanLibrary(): Observable<LibraryScanResultDto>;
scanLibrary(mappedLibrary: LibraryScanRequestDto): Observable<LibraryScanResultDto>;
downloadImages(): Observable<ImageDownloadResultDto>;
@@ -3,6 +3,7 @@ import {Observable} from "rxjs";
import {DetectedGameDto} from "../models/dtos/DetectedGameDto";
import {UnmappedFileDto} from "../models/dtos/UnmappedFileDto";
import {AutocompleteSuggestionDto} from "../models/dtos/AutocompleteSuggestionDto";
import {LibraryDto} from "../models/dtos/LibraryDto";
export interface LibraryManagementApi {
mapGame(pathToSlugDto: PathToSlugDto): Observable<DetectedGameDto>;
@@ -11,4 +12,5 @@ export interface LibraryManagementApi {
deleteGame(slug: string): Observable<Response>;
deleteUnmappedFile(id: number): Observable<Response>;
getAutocompleteSuggestions(searchTerm: string, limit: number): Observable<AutocompleteSuggestionDto[]>;
getLibraries(): Observable<LibraryDto[]>;
}
+4
View File
@@ -38,10 +38,12 @@ import {MatChipsModule} from "@angular/material/chips";
import { LibraryManagementComponent } from './components/library-management/library-management.component';
import {MatTooltipModule} from "@angular/material/tooltip";
import {MapGameDialogComponent} from "./components/map-game-dialog/map-game-dialog.component";
import {MapLibraryDialogComponent} from "./components/map-library-dialog/map-library-dialog.component";
import {MatSlideToggleModule} from "@angular/material/slide-toggle";
import {MatCheckboxModule} from "@angular/material/checkbox";
import {A11yModule} from "@angular/cdk/a11y";
import { MappedGamesTableComponent } from './components/mapped-games-table/mapped-games-table.component';
import { MappedLibrariesTableComponent } from './components/mapped-libraries-table/mapped-libraries-table.component';
import {MatTableFilterModule} from "mat-table-filter";
import { UnmappedFilesTableComponent } from './components/unmapped-files-table/unmapped-files-table.component';
import {MatDividerModule} from "@angular/material/divider";
@@ -68,7 +70,9 @@ import { ProgressBarColorDirective } from './directives/progress-bar-color.direc
GameVideoComponent,
LibraryManagementComponent,
MapGameDialogComponent,
MapLibraryDialogComponent,
MappedGamesTableComponent,
MappedLibrariesTableComponent,
UnmappedFilesTableComponent,
NgModelChangeDebouncedDirective,
ProgressBarColorDirective,
@@ -58,6 +58,14 @@
(click)="goToLibraryWithFilter('themes', theme.slug)">{{theme.name}}</mat-chip>
</mat-chip-list>
</div>
<div *ngIf="game.platforms !== undefined && game.platforms.length > 0">
<h2>Platforms</h2>
<mat-chip-list>
<mat-chip *ngFor="let platform of game.platforms" [disabled]="game.library == undefined || !hasPlatform(game.library, platform)"
(click)="goToLibraryWithFilter('platforms', platform.slug)">{{platform.name}}</mat-chip>
</mat-chip-list>
</div>
</div>
<div fxFlex fxLayout="row" fxLayoutGap="16px">
@@ -3,6 +3,8 @@ import {ActivatedRoute, Params, Router} from "@angular/router";
import {DetectedGameDto} from "../../models/dtos/DetectedGameDto";
import {GamesService} from "../../services/games.service";
import {CompanyDto} from "../../models/dtos/CompanyDto";
import {LibraryDto} from "../../models/dtos/LibraryDto";
import {PlatformDto} from "../../models/dtos/PlatformDto";
@Component({
selector: 'app-game-detail-view',
@@ -110,4 +112,8 @@ export class GameDetailViewComponent {
return Math.floor(containerWidth / elementWidth);
}
hasPlatform(library: LibraryDto, platform: PlatformDto) {
return library.platforms.some((libPlatform) => libPlatform.slug == platform.slug)
}
}
@@ -5,6 +5,7 @@ import {Router} from "@angular/router";
import {GamesService} from "../../services/games.service";
import {ThemingService} from "../../services/theming.service";
import {Location} from '@angular/common';
import {LibraryScanRequestDto} from "../../models/dtos/LibraryScanRequestDto";
@Component({
selector: 'app-header',
@@ -25,7 +26,9 @@ export class HeaderComponent {
}
scanLibrary(): void {
this.libraryService.scanLibrary().subscribe({
let request = new LibraryScanRequestDto();
request.downloadImages = true;
this.libraryService.scanLibrary(request).subscribe({
next: result => {
// Refresh the current page "angular style"
this.router.navigate([this.router.url]).then(() => {
@@ -1,6 +1,9 @@
<div fxFlexFill>
<div *ngIf="loggedIn && (this.unmappedFiles.length > 0 || this.mappedGames.length > 0)" fxFlex fxLayoutAlign="center start">
<div *ngIf="loggedIn && (this.mappedLibraries.length > 0)" fxFlex fxLayoutAlign="center start">
<mat-tab-group>
<mat-tab label="Library mappings">
<mapped-libraries-table [mappedLibraries]="mappedLibraries"></mapped-libraries-table>
</mat-tab>
<mat-tab label="Game mappings">
<mapped-games-table [mappedGames]="mappedGames"></mapped-games-table>
</mat-tab>
@@ -19,7 +22,7 @@
</div>
</div>
<div *ngIf="loggedIn && this.unmappedFiles.length === 0 && this.mappedGames.length === 0" fxFlex fxLayout="column" fxLayoutAlign="center center">
<div *ngIf="loggedIn && this.mappedLibraries.length === 0" fxFlex fxLayout="column" fxLayoutAlign="center center">
<div class="library-management-hint" fxLayout="column" fxLayoutAlign="start end">
<mat-icon fontSet="material-icons-outlined">north_east</mat-icon>
<p>Use the library management to scan your file system for games</p>
@@ -3,6 +3,7 @@ import {GamesService} from "../../services/games.service";
import {LibraryManagementService} from "../../services/library-management.service";
import {DetectedGameDto} from "../../models/dtos/DetectedGameDto";
import {UnmappedFileDto} from "../../models/dtos/UnmappedFileDto";
import {LibraryDto} from "../../models/dtos/LibraryDto";
@Component({
selector: 'app-library-management',
@@ -14,6 +15,7 @@ export class LibraryManagementComponent implements OnInit {
mappedGames: DetectedGameDto[] = [];
unmappedFiles: UnmappedFileDto[] = [];
mappedLibraries: LibraryDto[] = [];
constructor(private gamesService: GamesService,
private libraryManagementService: LibraryManagementService) {
@@ -25,6 +27,10 @@ export class LibraryManagementComponent implements OnInit {
this.unmappedFiles = uf;
this.loggedIn = true;
});
this.libraryManagementService.getLibraries().subscribe(libraries => {
this.mappedLibraries = libraries;
this.loggedIn = true;
});
}
}
@@ -114,6 +114,18 @@
color="primary">{{playerPerspective.name}}</mat-checkbox>
</div>
</mat-expansion-panel>
<mat-expansion-panel *ngIf="availablePlatforms.length > 0" [expanded]="activePlatformFilters.length > 0">
<mat-expansion-panel-header>
<h3 class="filter-category-title">Platforms</h3>
</mat-expansion-panel-header>
<div fxLayout="column">
<mat-checkbox *ngFor="let platform of availablePlatforms" (change)="togglePlatformFilter(platform.slug)"
[checked]="activePlatformFilters.includes(platform.slug)"
color="primary">{{platform.name}}</mat-checkbox>
</div>
</mat-expansion-panel>
</div>
<div fxFlex="0 1 1"><!--SPACER--></div>
@@ -6,6 +6,7 @@ import {ThemeDto} from "../../models/dtos/ThemeDto";
import {firstValueFrom, forkJoin, Observable} from "rxjs";
import {SortDirection} from "@angular/material/sort";
import {PlayerPerspectiveDto} from "../../models/dtos/PlayerPerspectiveDto";
import {PlatformDto} from "../../models/dtos/PlatformDto";
import {ActivatedRoute, ActivatedRouteSnapshot, Params, Router} from "@angular/router";
import {Location} from "@angular/common";
@@ -52,11 +53,13 @@ export class LibraryOverviewComponent implements AfterContentInit {
activeThemeFilters: string[] = [];
activeGenreFilters: string[] = [];
activePlayerPerspectiveFilters: string[] = [];
activePlatformFilters: string[] = [];
games: DetectedGameDto[] = [];
availableGenres: GenreDto[] = [];
availableThemes: ThemeDto[] = [];
availablePlayerPerspectives: PlayerPerspectiveDto[] = [];
availablePlatforms: PlatformDto[] = [];
loading: boolean = true;
gameLibraryIsEmpty: boolean = false;
@@ -82,11 +85,13 @@ export class LibraryOverviewComponent implements AfterContentInit {
let genreObservable: Observable<ThemeDto[]> = this.gameServerService.getAvailableGenres();
let themeObservable: Observable<GenreDto[]> = this.gameServerService.getAvailableThemes();
let playerPerspectiveObservable: Observable<PlayerPerspectiveDto[]> = this.gameServerService.getAvailablePlayerPerspectives();
let platformObservable: Observable<PlatformDto[]> = this.gameServerService.getAvailablePlatforms();
forkJoin([genreObservable, themeObservable, playerPerspectiveObservable]).subscribe(result => {
forkJoin([genreObservable, themeObservable, playerPerspectiveObservable, platformObservable]).subscribe(result => {
this.availableGenres = result[0];
this.availableThemes = result[1];
this.availablePlayerPerspectives = result[2];
this.availablePlatforms = result[3];
this.previousStateParams = this.route.snapshot.queryParams;
if (this.previousStateParams['search'] !== undefined) this.searchTerm = this.previousStateParams['search'];
@@ -95,6 +100,7 @@ export class LibraryOverviewComponent implements AfterContentInit {
if (this.previousStateParams['genres'] !== undefined) this.activeGenreFilters = this.matchSelectedFilters(this.availableGenres, this.previousStateParams['genres']);
if (this.previousStateParams['themes'] !== undefined) this.activeThemeFilters = this.matchSelectedFilters(this.availableThemes, this.previousStateParams['themes']);
if (this.previousStateParams['playerPerspectives'] !== undefined) this.activePlayerPerspectiveFilters = this.matchSelectedFilters(this.availablePlayerPerspectives, this.previousStateParams['playerPerspectives']);
if (this.previousStateParams['platforms'] !== undefined) this.activePlatformFilters = this.matchSelectedFilters(this.availablePlatforms, this.previousStateParams['platforms']);
this.refreshLibraryView().then(() => this.loading = false);
});
@@ -134,6 +140,11 @@ export class LibraryOverviewComponent implements AfterContentInit {
games = games.filter(game => this.activePlayerPerspectiveFilters.every(activePlayerPerspectiveFilter => game.playerPerspectives?.map(g => g.slug).includes(activePlayerPerspectiveFilter)));
}
if (this.activePlatformFilters.length > 0) {
games = games.filter(game => this.activePlatformFilters.some(activePlatformFilter =>
game?.library?.platforms?.map(g => g.slug).includes(activePlatformFilter) && game?.platforms?.map(g => g.slug).includes(activePlatformFilter)));
}
return games;
}
@@ -197,6 +208,21 @@ export class LibraryOverviewComponent implements AfterContentInit {
this.refreshLibraryView();
}
togglePlatformFilter(slug: string): void {
if (this.activePlatformFilters.includes(slug)) {
const index = this.activePlatformFilters.indexOf(slug, 0);
if (index > -1) {
this.activePlatformFilters.splice(index, 1);
}
} else {
this.activePlatformFilters.push(slug);
}
this.refreshLibraryView();
}
private saveStateToRoute(): void {
let newStateParams: Params = {};
@@ -206,6 +232,7 @@ export class LibraryOverviewComponent implements AfterContentInit {
if (this.activeGenreFilters.length > 0) newStateParams['genres'] = this.activeGenreFilters.join(',');
if (this.activeThemeFilters.length > 0) newStateParams['themes'] = this.activeThemeFilters.join(',');
if (this.activePlayerPerspectiveFilters.length > 0) newStateParams['playerPerspectives'] = this.activePlayerPerspectiveFilters.join(',');
if (this.activePlatformFilters.length > 0) newStateParams['platforms'] = this.activePlatformFilters.join(',');
// only update the route if it changed
if (JSON.stringify(this.previousStateParams) !== JSON.stringify(newStateParams)) {
@@ -12,7 +12,7 @@
<mat-autocomplete #igdbSlugAutocomplete="matAutocomplete">
<mat-option *ngFor="let suggestion of autocompleteSuggestions" [value]="suggestion.slug">
{{suggestion.title}} ({{getFullYearFromTimestamp(suggestion.releaseDate)}})
{{suggestion.title}} ({{getFullYearFromTimestamp(suggestion.releaseDate)}}) - {{suggestion.platforms.join(', ')}}
</mat-option>
</mat-autocomplete>
</mat-form-field>
@@ -0,0 +1,31 @@
<h3 mat-dialog-title>Map path to IGDB platform</h3>
<mat-dialog-content>
<form fxLayout="column" fxLayoutAlign="space-evenly stretch">
<p>Path: {{path}}</p>
<p><a href="https://www.igdb.com/platforms" target="_blank">Available platforms</a></p>
<mat-form-field>
<div fxLayout="row">
<input type="text" placeholder="IGDB Platform Slugs" matInput [matAutocomplete]="igdbPlatformSlugsAutocomplete" [(ngModel)]="slugs" (ngModelChangeDebounced)="loadSuggestions()" [ngModelOptions]="{standalone: true}">
<mat-spinner *ngIf="suggestionsLoading" [diameter]="16"></mat-spinner>
</div>
<mat-autocomplete #igdbPlatformSlugsAutocomplete="matAutocomplete">
<mat-option *ngFor="let suggestion of autocompletePlatformSuggestions" [value]="previousSlugs+suggestion.slug">
{{suggestion.name}}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</form>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-raised-button [mat-dialog-close]="false" color="accent" [disabled]="submitLoading">Cancel</button>
<button mat-raised-button (click)="submit()" [disabled]="slugs.length < 1 || submitLoading" color="primary">
<span *ngIf="!submitLoading">OK</span>
<div *ngIf="submitLoading" fxLayout="column" fxLayoutAlign="center center" style="height: 36px;">
<mat-spinner [diameter]="24"></mat-spinner>
</div>
</button>
</mat-dialog-actions>
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MapLibraryDialogComponent } from './map-library-dialog.component';
describe('MapLibraryDialogComponent', () => {
let component: MapLibraryDialogComponent;
let fixture: ComponentFixture<MapLibraryDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ MapLibraryDialogComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(MapLibraryDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,90 @@
import {Component, Inject, OnInit} from '@angular/core';
import {LibraryManagementService} from "../../services/library-management.service";
import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {PathToSlugDto} from "../../models/dtos/PathToSlugDto";
import {DialogService} from "../../services/dialog.service";
import {ApiErrorResponse} from "../../models/dtos/ApiErrorResponse";
import {PlatformDto} from "../../models/dtos/PlatformDto";
@Component({
selector: 'app-map-library-dialog',
templateUrl: './map-library-dialog.component.html',
styleUrls: ['./map-library-dialog.component.scss']
})
export class MapLibraryDialogComponent implements OnInit {
path: string;
slugs: string;
previousSlugs: string;
autocompletePlatformSuggestions: PlatformDto[] = [];
submitLoading: boolean = false;
suggestionsLoading: boolean = false;
constructor(private libraryManagementService: LibraryManagementService,
private dialogService: DialogService,
public dialogRef: MatDialogRef<MapLibraryDialogComponent>,
@Inject(MAT_DIALOG_DATA) data: any) {
this.path = data.path;
this.slugs = data.slugs ?? '';
this.previousSlugs = data.previousSlugs ?? '';
}
ngOnInit() {
this.loadInitialSuggestions();
}
submit(): void {
this.submitLoading = true;
this.libraryManagementService.mapLibrary(new PathToSlugDto(Array.isArray(this.slugs) ? this.slugs.join(',') : this.slugs, this.path)).subscribe({
next: () => this.dialogRef.close(true),
error: (error: ApiErrorResponse) => {
this.dialogRef.close(false);
this.dialogService.showErrorDialog(error.error.message);
}
}
)
}
loadInitialSuggestions(): void {
this.suggestionsLoading = true;
// Extract the last path element (folder name / file name)
let extractedPlatformFromPath: string = this.path.match(/([^\\/]*)[\\/]*$/)![1];
// Match it until the first special characters
extractedPlatformFromPath = extractedPlatformFromPath.match(/^[a-zA-Z0-9:\- ]+/)![0];
if(extractedPlatformFromPath == null) {
this.suggestionsLoading = false;
return;
}
this.libraryManagementService.getPlatforms(extractedPlatformFromPath, 10).subscribe({
next: suggestions => {
this.autocompletePlatformSuggestions = suggestions;
this.suggestionsLoading = false;
},
error: () => this.suggestionsLoading = false
})
}
loadSuggestions(): void {
this.suggestionsLoading = true;
let searchTerm = '';
if (this.slugs.length > 0) {
let slugArray = this.slugs.split(',');
// pop off the search term after the last comma
searchTerm = slugArray.pop() ?? '';
// if we already had slugs in our input field we need to add them back again
this.previousSlugs = (slugArray.length > 0 ? slugArray.join(',') + ',' : '');
}
this.libraryManagementService.getPlatforms(searchTerm, 50).subscribe({
next: suggestions => {
this.autocompletePlatformSuggestions = suggestions;
this.suggestionsLoading = false;
},
error: () => this.suggestionsLoading = false
})
}
}
@@ -0,0 +1,43 @@
<div class="mat-elevation-z8">
<table mat-table matSort matTableFilter [dataSource]="dataSource" [exampleEntity]="filter" [debounceTime]="0">
<!-- Path column -->
<ng-container matColumnDef="path">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Path</th>
<td mat-cell *matCellDef="let element"> {{element.path}} </td>
</ng-container>
<!-- Platform column -->
<ng-container matColumnDef="platforms">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Platforms</th>
<td mat-cell *matCellDef="let element"><span *ngFor="let item of element.platforms; let isLast=last">{{item.name}}{{isLast ? '' : ', '}}</span></td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>
<button mat-icon-button (click)="refreshMappedLibrariesList()">
<mat-icon matTooltip="Refresh library list" matTooltipPosition="below">refresh</mat-icon>
</button>
</th>
<!-- Action column -->
<td mat-cell *matCellDef="let element">
<button mat-icon-button (click)="openLibraryMappingDialog(element)">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button matTooltip="Scan this library" (click)="scanLibrary(element)">
<mat-icon>youtube_searched_for</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<mat-paginator
[length]="dataSource?.data?.length"
[pageIndex]="0"
[pageSize]="15"
[pageSizeOptions]="[10, 15, 25, 50]">
</mat-paginator>
</div>
@@ -0,0 +1,8 @@
table {
width: 50vw;
min-width: 750px;
}
.mat-column-actions {
width: 20%;
}
@@ -0,0 +1,34 @@
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { MappedLibrariesTableComponent } from './mapped-libraries-table.component';
describe('MappedLibrariesTableComponent', () => {
let component: MappedLibrariesTableComponent;
let fixture: ComponentFixture<MappedLibrariesTableComponent>;
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ MappedLibrariesTableComponent ],
imports: [
NoopAnimationsModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(MappedLibrariesTableComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should compile', () => {
expect(component).toBeTruthy();
});
});
@@ -0,0 +1,100 @@
import {AfterViewInit, Component, Input, OnChanges, SimpleChanges, ViewChild} from '@angular/core';
import {MatPaginator} from '@angular/material/paginator';
import {MatSort} from '@angular/material/sort';
import {MatTable, MatTableDataSource} from '@angular/material/table';
import {LibraryDto} from "../../models/dtos/LibraryDto";
import {LibraryScanRequestDto} from "../../models/dtos/LibraryScanRequestDto";
import {GamesService} from "../../services/games.service";
import {LibraryManagementService} from "../../services/library-management.service";
import {DialogService} from "../../services/dialog.service";
import {MatSnackBar} from '@angular/material/snack-bar';
import {Router} from "@angular/router";
import {LibraryService} from "../../services/library.service";
@Component({
selector: 'mapped-libraries-table',
templateUrl: './mapped-libraries-table.component.html',
styleUrls: ['./mapped-libraries-table.component.scss']
})
export class MappedLibrariesTableComponent implements AfterViewInit, OnChanges {
@ViewChild(MatPaginator) paginator!: MatPaginator;
@ViewChild(MatSort) sort!: MatSort;
@ViewChild(MatTable) table!: MatTable<LibraryDto>;
@Input() mappedLibraries!: LibraryDto[];
dataSource: MatTableDataSource<LibraryDto> = new MatTableDataSource();
displayedColumns: string[] = ["path", "platforms", "actions"];
filter: LibraryDto = new LibraryDto();
constructor(private libraryManagementService: LibraryManagementService,
private dialogService: DialogService,
private libraryService: LibraryService,
private snackBar: MatSnackBar,
private router: Router) {
}
ngAfterViewInit(): void {
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = (item: LibraryDto, property: string) => {
return (item as any)[property];
};
this.dataSource.paginator = this.paginator;
}
ngOnChanges(changes: SimpleChanges): void {
this.refreshData(changes['mappedLibraries'].currentValue);
}
refreshMappedLibrariesList(): void {
this.libraryManagementService.getLibraries().subscribe(libraries => this.refreshData(libraries));
}
openLibraryMappingDialog(mappedLibrary: LibraryDto): void {
this.dialogService.libraryMappingDialog(mappedLibrary).subscribe(librarySuccessfullyMapped => {
if (librarySuccessfullyMapped) this.refreshMappedLibrariesList();
})
}
scanLibrary(mappedLibrary: LibraryDto): void {
let request = new LibraryScanRequestDto();
request.path = mappedLibrary.path;
request.downloadImages = true;
this.libraryService.scanLibrary(request).subscribe({
next: result => {
// Refresh the current page "angular style"
this.router.navigate([this.router.url]).then(() => {
const snackBarDuration: number = 10000;
let snackbarContent: string = 'Library scan completed in ' + result.scanDuration + ' seconds:\n' +
'- ' + result.newGames + ' new games\n' +
'- ' + result.deletedGames + ' games removed\n' +
'- ' + result.newUnmappableFiles + ' files/folders could not be mapped\n' +
'- ' + result.totalGames + ' games currently in your library';
if (result.companyLogoDownloads !== undefined && result.coverDownloads !== undefined && result.screenshotDownloads !== undefined) {
snackbarContent = snackbarContent.concat('\n' +
'- ' + result.coverDownloads + ' covers downloaded\n' +
'- ' + result.screenshotDownloads + ' screenshots downloaded\n' +
'- ' + result.companyLogoDownloads + ' company logos downloaded');
}
this.snackBar.open(snackbarContent, undefined, {duration: snackBarDuration});
}
)
},
error: error => this.snackBar.open(`Error while scanning library: ${error.error.message}`, undefined, {duration: 5000})
})
this.snackBar.open('Library scan started in the background. This could take some time.\nYou will get another notification once it\'s done', undefined, {duration: 5000})
}
private refreshData(newData: LibraryDto[]): void {
this.dataSource.data = newData;
// Dirty hack to force a re-render
// Did not find a better solution
this.paginator?._changePageSize(this.paginator?.pageSize);
}
}
@@ -2,4 +2,5 @@ export class AutocompleteSuggestionDto {
slug!: string;
title!: string;
releaseDate!: number;
platforms!: Array<string>;
}
@@ -2,7 +2,9 @@ import {CompanyDto} from "./CompanyDto";
import {GenreDto} from "./GenreDto";
import {KeywordDto} from "./KeywordDto";
import {PlayerPerspectiveDto} from "./PlayerPerspectiveDto";
import {PlatformDto} from "./PlatformDto";
import {ThemeDto} from "./ThemeDto";
import {LibraryDto} from "./LibraryDto";
export class DetectedGameDto {
@@ -26,6 +28,8 @@ export class DetectedGameDto {
keywords?: KeywordDto[];
themes?: ThemeDto[];
playerPerspectives?: PlayerPerspectiveDto[];
platforms?: PlatformDto[];
library?: LibraryDto;
path!: string;
diskSize!: number;
@@ -0,0 +1,7 @@
import {PlatformDto} from "./PlatformDto";
export class LibraryDto {
path!: string;
platforms!: PlatformDto[];
}
@@ -0,0 +1,7 @@
import {PlatformDto} from "./PlatformDto";
export class LibraryScanRequestDto {
path!: string;
downloadImages!: boolean;
}
@@ -0,0 +1,5 @@
export class PlatformDto {
slug!: string;
name!: string;
platformLogoId?: string;
}
+20 -2
View File
@@ -3,7 +3,9 @@ import {MatDialog, MatDialogConfig} from '@angular/material/dialog';
import {ErrorDialogComponent} from '../components/error-dialog/error-dialog.component';
import {DetectedGameDto} from "../models/dtos/DetectedGameDto";
import {MapGameDialogComponent} from "../components/map-game-dialog/map-game-dialog.component";
import {MapLibraryDialogComponent} from "../components/map-library-dialog/map-library-dialog.component";
import {UnmappedFileDto} from "../models/dtos/UnmappedFileDto";
import {LibraryDto} from "../models/dtos/LibraryDto";
import {Observable} from "rxjs";
@Injectable({
@@ -35,7 +37,7 @@ export class DialogService {
dialogConfig.disableClose = true;
dialogConfig.autoFocus = true;
dialogConfig.closeOnNavigation = true;
dialogConfig.minWidth = '25vw';
dialogConfig.minWidth = '40vw';
dialogConfig.data = {
path: game.path,
@@ -51,7 +53,7 @@ export class DialogService {
dialogConfig.disableClose = true;
dialogConfig.autoFocus = true;
dialogConfig.closeOnNavigation = true;
dialogConfig.minWidth = '25vw';
dialogConfig.minWidth = '40vw';
dialogConfig.data = {
path: unmappedFile.path
@@ -60,4 +62,20 @@ export class DialogService {
return this.dialog.open(MapGameDialogComponent, dialogConfig).afterClosed();
}
public libraryMappingDialog(library: LibraryDto): Observable<any> {
const dialogConfig = new MatDialogConfig();
dialogConfig.disableClose = true;
dialogConfig.autoFocus = true;
dialogConfig.closeOnNavigation = true;
dialogConfig.minWidth = '40vw';
dialogConfig.data = {
path: library.path,
slugs: library.platforms.map((platform) => platform.slug)
};
return this.dialog.open(MapLibraryDialogComponent, dialogConfig).afterClosed();
}
}
@@ -5,6 +5,7 @@ import {distinct, map, Observable} from "rxjs";
import {DetectedGameDto} from "../models/dtos/DetectedGameDto";
import {GameOverviewDto} from "../models/dtos/GameOverviewDto";
import {GenreDto} from "../models/dtos/GenreDto";
import {PlatformDto} from "../models/dtos/PlatformDto";
import {ThemeDto} from "../models/dtos/ThemeDto";
import {CompanyDto} from "../models/dtos/CompanyDto";
import {PlayerPerspectiveDto} from "../models/dtos/PlayerPerspectiveDto";
@@ -88,6 +89,20 @@ export class GamesService implements GamesApi {
);
}
// TODO: This method of removing duplicates is most certainly an anti-pattern in RxJS
// TODO: However, I did not get the 'distinct()' pipe to work properly, so I have to take another look in the future
getAvailablePlatforms(): Observable<PlatformDto[]> {
return this.getAllGames().pipe(
map<DetectedGameDto[], PlatformDto[]>(
games => {
let availablePlatformsMap: Map<string, PlatformDto> = new Map<string, PlatformDto>;
games.map(game => game.library !== undefined && game.library.platforms.length > 0 ? game.library.platforms : []).flat().forEach(platform => availablePlatformsMap.set(platform.slug, platform));
return Array.from(availablePlatformsMap.values()).sort((p1, p2) => p1.name.localeCompare(p2.name));
}
)
);
}
downloadGame(slug: String): void {
window.open(`v1${this.apiPath}/game/${slug}/download`, '_top');
}
@@ -7,6 +7,8 @@ import {UnmappedFileDto} from "../models/dtos/UnmappedFileDto";
import {LibraryManagementApi} from "../api/LibraryManagementApi";
import {GamesService} from "./games.service";
import {AutocompleteSuggestionDto} from "../models/dtos/AutocompleteSuggestionDto";
import {LibraryDto} from "../models/dtos/LibraryDto";
import {PlatformDto} from "../models/dtos/PlatformDto";
@Injectable({
providedIn: 'root'
@@ -50,4 +52,20 @@ export class LibraryManagementService implements LibraryManagementApi {
return this.http.get<AutocompleteSuggestionDto[]>(`${this.apiPath}/autocomplete-suggestions`, {params:queryParams})
}
getPlatforms(searchTerm: string, limit: number): Observable<PlatformDto[]> {
let queryParams = new HttpParams();
queryParams = queryParams.append("searchTerm", searchTerm);
queryParams = queryParams.append("limit", limit);
return this.http.get<PlatformDto[]>(`${this.apiPath}/platforms`, {params:queryParams})
}
mapLibrary(pathToSlugDto: PathToSlugDto): Observable<LibraryDto> {
return this.http.post<LibraryDto>(`${this.apiPath}/map-library`, pathToSlugDto);
}
getLibraries(): Observable<LibraryDto[]> {
return this.http.get<LibraryDto[]>(`${this.apiPath}/libraries`);
}
}
+4 -2
View File
@@ -4,6 +4,8 @@ import {Observable} from "rxjs";
import {LibraryApi} from "../api/LibraryApi";
import {LibraryScanResultDto} from "../models/dtos/LibraryScanResultDto";
import {ImageDownloadResultDto} from "../models/dtos/ImageDownloadResultDto";
import {LibraryDto} from "../models/dtos/LibraryDto";
import {LibraryScanRequestDto} from "../models/dtos/LibraryScanRequestDto";
@Injectable({
providedIn: 'root'
@@ -15,8 +17,8 @@ export class LibraryService implements LibraryApi {
constructor(private http: HttpClient) {
}
scanLibrary(): Observable<LibraryScanResultDto> {
return this.http.get<LibraryScanResultDto>(`${this.apiPath}/scan`);
scanLibrary(library: LibraryScanRequestDto): Observable<LibraryScanResultDto> {
return this.http.post<LibraryScanResultDto>(`${this.apiPath}/scan`, library);
}
downloadImages(): Observable<ImageDownloadResultDto> {