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