(WIP) Implement game management

This commit is contained in:
grimsi
2025-06-12 15:57:12 +02:00
parent f5e9486246
commit 1e242cd7ae
21 changed files with 1240 additions and 922 deletions
+800 -800
View File
File diff suppressed because it is too large Load Diff
+54 -54
View File
@@ -9,22 +9,22 @@
"@polymer/polymer": "3.5.2", "@polymer/polymer": "3.5.2",
"@react-stately/data": "^3.12.2", "@react-stately/data": "^3.12.2",
"@react-types/shared": "^3.28.0", "@react-types/shared": "^3.28.0",
"@vaadin/bundles": "24.8.0-beta1", "@vaadin/bundles": "24.8.0-rc1",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.8.0-beta2", "@vaadin/hilla-file-router": "24.8.0-rc1",
"@vaadin/hilla-frontend": "24.8.0-beta2", "@vaadin/hilla-frontend": "24.8.0-rc1",
"@vaadin/hilla-lit-form": "24.8.0-beta2", "@vaadin/hilla-lit-form": "24.8.0-rc1",
"@vaadin/hilla-react-auth": "24.8.0-beta2", "@vaadin/hilla-react-auth": "24.8.0-rc1",
"@vaadin/hilla-react-crud": "24.8.0-beta2", "@vaadin/hilla-react-crud": "24.8.0-rc1",
"@vaadin/hilla-react-form": "24.8.0-beta2", "@vaadin/hilla-react-form": "24.8.0-rc1",
"@vaadin/hilla-react-i18n": "24.8.0-beta2", "@vaadin/hilla-react-i18n": "24.8.0-rc1",
"@vaadin/hilla-react-signals": "24.8.0-beta2", "@vaadin/hilla-react-signals": "24.8.0-rc1",
"@vaadin/polymer-legacy-adapter": "24.8.0-beta1", "@vaadin/polymer-legacy-adapter": "24.8.0-rc1",
"@vaadin/react-components": "24.8.0-beta1", "@vaadin/react-components": "24.8.0-rc1",
"@vaadin/vaadin-development-mode-detector": "2.0.7", "@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.8.0-beta1", "@vaadin/vaadin-lumo-styles": "24.8.0-rc1",
"@vaadin/vaadin-material-styles": "24.8.0-beta1", "@vaadin/vaadin-material-styles": "24.8.0-rc1",
"@vaadin/vaadin-themable-mixin": "24.8.0-beta1", "@vaadin/vaadin-themable-mixin": "24.8.0-rc1",
"@vaadin/vaadin-usage-statistics": "2.1.3", "@vaadin/vaadin-usage-statistics": "2.1.3",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"construct-style-sheets-polyfill": "3.1.0", "construct-style-sheets-polyfill": "3.1.0",
@@ -60,19 +60,19 @@
"@rollup/plugin-replace": "6.0.2", "@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4", "@rollup/pluginutils": "5.1.4",
"@types/node": "^22.4.0", "@types/node": "^22.4.0",
"@types/react": "18.3.22", "@types/react": "18.3.23",
"@types/react-dom": "18.3.7", "@types/react-dom": "18.3.7",
"@vaadin/hilla-generator-cli": "24.8.0-beta2", "@vaadin/hilla-generator-cli": "24.8.0-rc1",
"@vaadin/hilla-generator-core": "24.8.0-beta2", "@vaadin/hilla-generator-core": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-backbone": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-backbone": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-barrel": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-barrel": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-client": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-client": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-model": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-model": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-push": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-push": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-signals": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-signals": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-subtypes": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-transfertypes": "24.8.0-rc1",
"@vaadin/hilla-generator-utils": "24.8.0-beta2", "@vaadin/hilla-generator-utils": "24.8.0-rc1",
"@vitejs/plugin-react": "4.5.0", "@vitejs/plugin-react": "4.5.0",
"@vitejs/plugin-react-swc": "^3.7.0", "@vitejs/plugin-react-swc": "^3.7.0",
"async": "3.2.6", "async": "3.2.6",
@@ -148,22 +148,22 @@
"vaadin": { "vaadin": {
"dependencies": { "dependencies": {
"@polymer/polymer": "3.5.2", "@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.8.0-beta1", "@vaadin/bundles": "24.8.0-rc1",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.8.0-beta2", "@vaadin/hilla-file-router": "24.8.0-rc1",
"@vaadin/hilla-frontend": "24.8.0-beta2", "@vaadin/hilla-frontend": "24.8.0-rc1",
"@vaadin/hilla-lit-form": "24.8.0-beta2", "@vaadin/hilla-lit-form": "24.8.0-rc1",
"@vaadin/hilla-react-auth": "24.8.0-beta2", "@vaadin/hilla-react-auth": "24.8.0-rc1",
"@vaadin/hilla-react-crud": "24.8.0-beta2", "@vaadin/hilla-react-crud": "24.8.0-rc1",
"@vaadin/hilla-react-form": "24.8.0-beta2", "@vaadin/hilla-react-form": "24.8.0-rc1",
"@vaadin/hilla-react-i18n": "24.8.0-beta2", "@vaadin/hilla-react-i18n": "24.8.0-rc1",
"@vaadin/hilla-react-signals": "24.8.0-beta2", "@vaadin/hilla-react-signals": "24.8.0-rc1",
"@vaadin/polymer-legacy-adapter": "24.8.0-beta1", "@vaadin/polymer-legacy-adapter": "24.8.0-rc1",
"@vaadin/react-components": "24.8.0-beta1", "@vaadin/react-components": "24.8.0-rc1",
"@vaadin/vaadin-development-mode-detector": "2.0.7", "@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.8.0-beta1", "@vaadin/vaadin-lumo-styles": "24.8.0-rc1",
"@vaadin/vaadin-material-styles": "24.8.0-beta1", "@vaadin/vaadin-material-styles": "24.8.0-rc1",
"@vaadin/vaadin-themable-mixin": "24.8.0-beta1", "@vaadin/vaadin-themable-mixin": "24.8.0-rc1",
"@vaadin/vaadin-usage-statistics": "2.1.3", "@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0", "construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3", "date-fns": "2.29.3",
@@ -177,19 +177,19 @@
"@preact/signals-react-transform": "0.5.1", "@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2", "@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4", "@rollup/pluginutils": "5.1.4",
"@types/react": "18.3.22", "@types/react": "18.3.23",
"@types/react-dom": "18.3.7", "@types/react-dom": "18.3.7",
"@vaadin/hilla-generator-cli": "24.8.0-beta2", "@vaadin/hilla-generator-cli": "24.8.0-rc1",
"@vaadin/hilla-generator-core": "24.8.0-beta2", "@vaadin/hilla-generator-core": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-backbone": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-backbone": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-barrel": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-barrel": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-client": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-client": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-model": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-model": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-push": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-push": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-signals": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-signals": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-subtypes": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.0-beta2", "@vaadin/hilla-generator-plugin-transfertypes": "24.8.0-rc1",
"@vaadin/hilla-generator-utils": "24.8.0-beta2", "@vaadin/hilla-generator-utils": "24.8.0-rc1",
"@vitejs/plugin-react": "4.5.0", "@vitejs/plugin-react": "4.5.0",
"async": "3.2.6", "async": "3.2.6",
"glob": "11.0.2", "glob": "11.0.2",
@@ -205,6 +205,6 @@
"workbox-core": "7.3.0", "workbox-core": "7.3.0",
"workbox-precaching": "7.3.0" "workbox-precaching": "7.3.0"
}, },
"hash": "86f3ea527539716295163ae736b2b548fb6d35efd20c87ae1b109c727817cc71" "hash": "b2ffd3ce9b28bc9b88c070ed3a53ff5a6db02bd4f3127932d7c0be123cf7be25"
} }
} }
@@ -1,28 +1,108 @@
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto"; import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
import {Button, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@heroui/react"; import {
Button,
Pagination,
Select,
SelectItem,
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
Tooltip
} from "@heroui/react";
import {CheckCircle, Pencil, Trash} from "@phosphor-icons/react"; import {CheckCircle, Pencil, Trash} from "@phosphor-icons/react";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import {GameEndpoint} from "Frontend/generated/endpoints";
import GameUpdateDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameUpdateDto";
import {useMemo, useState} from "react";
interface LibraryManagementGamesProps { interface LibraryManagementGamesProps {
library: LibraryDto; library: LibraryDto;
} }
export default function LibraryManagementGames({library}: LibraryManagementGamesProps) { export default function LibraryManagementGames({library}: LibraryManagementGamesProps) {
const rowsPerPage = 25;
const state = useSnapshot(gameState); const state = useSnapshot(gameState);
const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameDto[] : undefined; const games = state.gamesByLibraryId[library.id] ? state.gamesByLibraryId[library.id] as GameDto[] : [];
const [filter, setFilter] = useState<"all" | "confirmed" | "nonConfirmed">("all");
const [page, setPage] = useState(1);
const pages = useMemo(() => {
return Math.ceil(getFilteredGames().length / rowsPerPage);
}, [games, filter]);
const items = useMemo(() => {
const start = (page - 1) * rowsPerPage;
const end = start + rowsPerPage;
return getFilteredGames().slice(start, end);
}, [page, games, filter]);
function getFilteredGames() {
if (filter === "confirmed") {
return games.filter(g => g.metadata.matchConfirmed);
}
if (filter === "nonConfirmed") {
return games.filter(g => !g.metadata.matchConfirmed);
}
return games;
}
async function toggleMatchConfirmed(game: GameDto) {
await GameEndpoint.updateGame(
{
id: game.id,
metadata: {matchConfirmed: !game.metadata.matchConfirmed}
} as GameUpdateDto
)
}
async function deleteGame(game: GameDto) {
await GameEndpoint.deleteGame(game.id);
}
return <div className="flex flex-col gap-4"> return <div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage games in library</h1> <h1 className="text-2xl font-bold">Manage games in library</h1>
<Table removeWrapper isStriped isHeaderSticky> <div className="flex flex-row gap-2 justify-end">
<Select
selectedKeys={[filter]}
disallowEmptySelection
onSelectionChange={keys => setFilter(Array.from(keys)[0] as any)}
className="w-64"
>
<SelectItem key="all">Show all</SelectItem>
<SelectItem key="confirmed">Show only confirmed</SelectItem>
<SelectItem key="nonConfirmed">Show only non confirmed</SelectItem>
</Select>
</div>
<Table removeWrapper isStriped isHeaderSticky
bottomContent={
<div className="flex w-full justify-center">
{items.length > 0 &&
<Pagination
isCompact
showControls
showShadow
color="primary"
page={page}
total={pages}
onChange={(page) => setPage(page)}
/>}
</div>
}>
<TableHeader> <TableHeader>
<TableColumn allowsSorting>Game</TableColumn> <TableColumn allowsSorting>Game</TableColumn>
<TableColumn allowsSorting>Added to library</TableColumn> <TableColumn allowsSorting>Added to library</TableColumn>
<TableColumn>Path</TableColumn> <TableColumn>Path</TableColumn>
<TableColumn>Actions</TableColumn> {/* width={1} keeps the column as far to the right as possible*/}
<TableColumn width={1}>Actions</TableColumn>
</TableHeader> </TableHeader>
<TableBody emptyContent="This library is empty." items={games}> <TableBody emptyContent="Your filter did not match any games." items={items}>
{(item) => ( {(item) => (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell> <TableCell>
@@ -35,9 +115,18 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
{item.metadata.path} {item.metadata.path}
</TableCell> </TableCell>
<TableCell className="flex flex-row gap-2"> <TableCell className="flex flex-row gap-2">
<Button isIconOnly size="sm" isDisabled={true}><CheckCircle/></Button> <Button isIconOnly size="sm" onPress={() => toggleMatchConfirmed(item)}>
{item.metadata.matchConfirmed ?
<Tooltip content="Unconfirm match">
<CheckCircle weight="fill" className="fill-success"/>
</Tooltip> :
<Tooltip content="Confirm match">
<CheckCircle/>
</Tooltip>}
</Button>
<Button isIconOnly size="sm" isDisabled={true}><Pencil/></Button> <Button isIconOnly size="sm" isDisabled={true}><Pencil/></Button>
<Button isIconOnly size="sm" isDisabled={true} color="danger"><Trash/></Button> <Button isIconOnly size="sm" color="danger"
onPress={() => deleteGame(item)}><Trash/></Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -0,0 +1,81 @@
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {Button, Pagination, Table, TableBody, TableCell, TableColumn, TableHeader, TableRow} from "@heroui/react";
import {Trash} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import {useMemo, useState} from "react";
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
import {hashCode} from "Frontend/util/utils";
interface LibraryManagementUnmatchedPathsProps {
library: LibraryDto;
}
export default function LibraryManagementUnmatchedPaths({library}: LibraryManagementUnmatchedPathsProps) {
const rowsPerPage = 25;
const [page, setPage] = useState(1);
const pages = useMemo(() => {
return Math.ceil(library.unmatchedPaths!.length / rowsPerPage);
}, [library]);
const items = useMemo(() => {
const start = (page - 1) * rowsPerPage;
const end = start + rowsPerPage;
return unmatchedPathItems().slice(start, end);
}, [page, library]);
async function deleteUnmatchedPath(unmatchedPath: string) {
const libraryUpdateDto: LibraryUpdateDto = {
id: library.id,
unmatchedPaths: library.unmatchedPaths!.filter((path) => path !== unmatchedPath)
}
await LibraryEndpoint.updateLibrary(libraryUpdateDto);
}
function unmatchedPathItems(): UnmatchedPathItem[] {
return library.unmatchedPaths!.map((path) => ({
key: hashCode(path),
path: path
}));
}
return <div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Manage unmatched paths</h1>
<Table removeWrapper isStriped isHeaderSticky
bottomContent={
<div className="flex w-full justify-center">
{items.length > 0 &&
<Pagination
isCompact
showControls
showShadow
color="primary"
page={page}
total={pages}
onChange={(page) => setPage(page)}
/>}
</div>
}>
<TableHeader>
<TableColumn allowsSorting>Path</TableColumn>
<TableColumn width={1}>Actions</TableColumn>
</TableHeader>
<TableBody emptyContent="This library has no unmatched paths." items={items}>
{(item) => (
<TableRow key={item.key}>
<TableCell>
{item.path}
</TableCell>
<TableCell className="flex flex-row gap-2">
<Button isIconOnly size="sm" color="danger"
onPress={() => deleteUnmatchedPath(item.path)}><Trash/>
</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>;
}
type UnmatchedPathItem = { key: number; path: string };
@@ -1,5 +1,5 @@
import React from "react"; import React, {useState} from "react";
import {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; import {addToast, Button, Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik"; import {Form, Formik} from "formik";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {LibraryEndpoint} from "Frontend/generated/endpoints";
@@ -20,23 +20,25 @@ export default function LibraryCreationModal({
onOpenChange onOpenChange
}: LibraryCreationModalProps) { }: LibraryCreationModalProps) {
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
async function createLibrary(library: LibraryDto) { async function createLibrary(library: LibraryDto) {
try { try {
await LibraryEndpoint.createLibrary(library as LibraryDto); await LibraryEndpoint.createLibrary(library as LibraryDto, scanAfterCreation);
addToast({
title: "New library created",
description: `Library ${library.name} created!`,
color: "success"
});
} catch (e) { } catch (e) {
addToast({ addToast({
title: "Error creating library", title: "Error creating library",
description: `Library ${library.name} could not be created!`, description: `Library ${library.name} could not be created!`,
color: "warning" color: "warning"
}); });
return; throw "Error creating library: " + e;
} }
addToast({
title: "New library created",
description: `Library ${library.name} created!`,
color: "success"
});
} }
return ( return (
@@ -74,17 +76,21 @@ export default function LibraryCreationModal({
<DirectoryMappingInput name="directories"/> <DirectoryMappingInput name="directories"/>
</div> </div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter className="flex flex-row justify-between">
<Button variant="light" onPress={onClose}> <Checkbox isSelected={scanAfterCreation} onValueChange={setScanAfterCreation}>Scan
Cancel after creation?</Checkbox>
</Button> <div className="flex flex-row">
<Button color="primary" <Button variant="light" onPress={onClose}>
isLoading={formik.isSubmitting} Cancel
isDisabled={formik.isSubmitting} </Button>
type="submit" <Button color="primary"
> isLoading={formik.isSubmitting}
{formik.isSubmitting ? "" : "Add"} isDisabled={formik.isSubmitting}
</Button> type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</div>
</ModalFooter> </ModalFooter>
</Form> </Form>
} }
+11
View File
@@ -18,6 +18,17 @@ export function camelCaseToTitle(text: string): string {
.replace(/^./, str => str.toUpperCase()); .replace(/^./, str => str.toUpperCase());
} }
export function hashCode(string: string) {
let hash = 0, i, chr;
if (string.length === 0) return hash;
for (i = 0; i < string.length; i++) {
chr = string.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
}
export function roleToColor(role: string) { export function roleToColor(role: string) {
switch (role) { switch (role) {
case "ROLE_SUPERADMIN": case "ROLE_SUPERADMIN":
@@ -7,6 +7,7 @@ import LibraryManagementDetails from "Frontend/components/general/library/Librar
import LibraryManagementGames from "Frontend/components/general/library/LibraryManagementGames"; import LibraryManagementGames from "Frontend/components/general/library/LibraryManagementGames";
import {useSnapshot} from "valtio/react"; import {useSnapshot} from "valtio/react";
import {initializeLibraryState, libraryState} from "Frontend/state/LibraryState"; import {initializeLibraryState, libraryState} from "Frontend/state/LibraryState";
import LibraryManagementUnmatchedPaths from "Frontend/components/general/library/LibraryManagementUnmatchedPaths";
export default function LibraryManagementView() { export default function LibraryManagementView() {
@@ -41,7 +42,8 @@ export default function LibraryManagementView() {
<LibraryManagementGames library={state.state[libraryId]}/> <LibraryManagementGames library={state.state[libraryId]}/>
</Tab> </Tab>
<Tab title="Unmatched paths"> <Tab title="Unmatched paths">
<p>Unmatched paths</p> {/* @ts-ignore */}
<LibraryManagementUnmatchedPaths library={state.state[libraryId]}/>
</Tab> </Tab>
</Tabs> </Tabs>
</div>; </div>;
@@ -5,6 +5,7 @@ import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.games.dto.GameDto import de.grimsi.gameyfin.games.dto.GameDto
import de.grimsi.gameyfin.games.dto.GameEvent import de.grimsi.gameyfin.games.dto.GameEvent
import de.grimsi.gameyfin.games.dto.GameUpdateDto import de.grimsi.gameyfin.games.dto.GameUpdateDto
import de.grimsi.gameyfin.libraries.LibraryService
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
@@ -12,7 +13,8 @@ import reactor.core.publisher.Flux
@Endpoint @Endpoint
@PermitAll @PermitAll
class GameEndpoint( class GameEndpoint(
private val gameService: GameService private val gameService: GameService,
private val libraryService: LibraryService
) { ) {
fun subscribe(): Flux<List<GameEvent>> { fun subscribe(): Flux<List<GameEvent>> {
return GameService.subscribe() return GameService.subscribe()
@@ -24,5 +26,8 @@ class GameEndpoint(
fun updateGame(game: GameUpdateDto) = gameService.update(game) fun updateGame(game: GameUpdateDto) = gameService.update(game)
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun deleteGame(gameId: Long) = gameService.delete(gameId) fun deleteGame(gameId: Long) {
libraryService.deleteGameFromLibrary(gameId)
gameService.delete(gameId)
}
} }
@@ -90,6 +90,9 @@ class GameService(
gameUpdateDto.title?.let { existingGame.title = it } gameUpdateDto.title?.let { existingGame.title = it }
gameUpdateDto.comment?.let { existingGame.comment = it } gameUpdateDto.comment?.let { existingGame.comment = it }
gameUpdateDto.summary?.let { existingGame.summary = it } gameUpdateDto.summary?.let { existingGame.summary = it }
gameUpdateDto.metadata?.let { metadata ->
metadata.matchConfirmed?.let { existingGame.metadata.matchConfirmed = it }
}
gameRepository.save(existingGame) gameRepository.save(existingGame)
} }
@@ -129,6 +132,12 @@ class GameService(
return gameRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("Game with id $id not found") return gameRepository.findByIdOrNull(id) ?: throw IllegalArgumentException("Game with id $id not found")
} }
fun setMatchConfirmed(gameId: Long, confirmed: Boolean) {
val game = getById(gameId)
game.metadata.matchConfirmed = confirmed
gameRepository.save(game)
}
/** /**
* Queries all metadata plugins for metadata on the provided game title * Queries all metadata plugins for metadata on the provided game title
* Runs the queries concurrently and asynchronously * Runs the queries concurrently and asynchronously
@@ -206,81 +215,91 @@ class GameService(
metadata.title.takeIf { it.isNotBlank() }?.let { title -> metadata.title.takeIf { it.isNotBlank() }?.let { title ->
if (!metadataMap.containsKey("title")) { if (!metadataMap.containsKey("title")) {
mergedGame.title = title mergedGame.title = title
metadataMap["title"] = GameFieldMetadata(source = sourcePlugin) metadataMap["title"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.description?.takeIf { it.isNotBlank() }?.let { description -> metadata.description?.takeIf { it.isNotBlank() }?.let { description ->
if (!metadataMap.containsKey("summary")) { if (!metadataMap.containsKey("summary")) {
mergedGame.summary = description mergedGame.summary = description
metadataMap["summary"] = GameFieldMetadata(source = sourcePlugin) metadataMap["summary"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.coverUrl?.let { coverUrl -> metadata.coverUrl?.let { coverUrl ->
if (!metadataMap.containsKey("coverImage")) { if (!metadataMap.containsKey("coverImage")) {
mergedGame.coverImage = Image(originalUrl = coverUrl.toURL(), type = ImageType.COVER) mergedGame.coverImage = Image(originalUrl = coverUrl.toURL(), type = ImageType.COVER)
metadataMap["coverImage"] = GameFieldMetadata(source = sourcePlugin) metadataMap["coverImage"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.release?.let { release -> metadata.release?.let { release ->
if (!metadataMap.containsKey("release")) { if (!metadataMap.containsKey("release")) {
mergedGame.release = release mergedGame.release = release
metadataMap["release"] = GameFieldMetadata(source = sourcePlugin) metadataMap["release"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.userRating?.let { userRating -> metadata.userRating?.let { userRating ->
if (!metadataMap.containsKey("userRating")) { if (!metadataMap.containsKey("userRating")) {
mergedGame.userRating = userRating mergedGame.userRating = userRating
metadataMap["userRating"] = GameFieldMetadata(source = sourcePlugin) metadataMap["userRating"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.criticRating?.let { criticRating -> metadata.criticRating?.let { criticRating ->
if (!metadataMap.containsKey("criticRating")) { if (!metadataMap.containsKey("criticRating")) {
mergedGame.criticRating = criticRating mergedGame.criticRating = criticRating
metadataMap["criticRating"] = GameFieldMetadata(source = sourcePlugin) metadataMap["criticRating"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy -> metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy ->
if (!metadataMap.containsKey("publishers")) { if (!metadataMap.containsKey("publishers")) {
mergedGame.publishers = mergedGame.publishers =
publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) } publishedBy.map { Company(name = it, type = CompanyType.PUBLISHER) }
metadataMap["publishers"] = GameFieldMetadata(source = sourcePlugin) metadataMap["publishers"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy -> metadata.developedBy?.takeIf { it.isNotEmpty() }?.let { developedBy ->
if (!metadataMap.containsKey("developers")) { if (!metadataMap.containsKey("developers")) {
mergedGame.developers = mergedGame.developers =
developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) } developedBy.map { Company(name = it, type = CompanyType.DEVELOPER) }
metadataMap["developers"] = GameFieldMetadata(source = sourcePlugin) metadataMap["developers"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.genres?.takeIf { it.isNotEmpty() }?.let { genres -> metadata.genres?.takeIf { it.isNotEmpty() }?.let { genres ->
if (!metadataMap.containsKey("genres")) { if (!metadataMap.containsKey("genres")) {
mergedGame.genres = genres.toList() mergedGame.genres = genres.toList()
metadataMap["genres"] = GameFieldMetadata(source = sourcePlugin) metadataMap["genres"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.themes?.takeIf { it.isNotEmpty() }?.let { themes -> metadata.themes?.takeIf { it.isNotEmpty() }?.let { themes ->
if (!metadataMap.containsKey("themes")) { if (!metadataMap.containsKey("themes")) {
mergedGame.themes = themes.toList() mergedGame.themes = themes.toList()
metadataMap["themes"] = GameFieldMetadata(source = sourcePlugin) metadataMap["themes"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.keywords?.takeIf { it.isNotEmpty() }?.let { keywords -> metadata.keywords?.takeIf { it.isNotEmpty() }?.let { keywords ->
if (!metadataMap.containsKey("keywords")) { if (!metadataMap.containsKey("keywords")) {
mergedGame.keywords = keywords.toList() mergedGame.keywords = keywords.toList()
metadataMap["keywords"] = GameFieldMetadata(source = sourcePlugin) metadataMap["keywords"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.features?.takeIf { it.isNotEmpty() }?.let { features -> metadata.features?.takeIf { it.isNotEmpty() }?.let { features ->
if (!metadataMap.containsKey("features")) { if (!metadataMap.containsKey("features")) {
mergedGame.features = features.toList() mergedGame.features = features.toList()
metadataMap["features"] = GameFieldMetadata(source = sourcePlugin) metadataMap["features"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.perspectives?.takeIf { it.isNotEmpty() }?.let { perspectives -> metadata.perspectives?.takeIf { it.isNotEmpty() }?.let { perspectives ->
if (!metadataMap.containsKey("perspectives")) { if (!metadataMap.containsKey("perspectives")) {
mergedGame.perspectives = perspectives.toList() mergedGame.perspectives = perspectives.toList()
metadataMap["perspectives"] = GameFieldMetadata(source = sourcePlugin) metadataMap["perspectives"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls -> metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls ->
@@ -288,13 +307,14 @@ class GameService(
mergedGame.images = runBlocking { mergedGame.images = runBlocking {
screenshotUrls.map { Image(originalUrl = it.toURL(), type = ImageType.SCREENSHOT) } screenshotUrls.map { Image(originalUrl = it.toURL(), type = ImageType.SCREENSHOT) }
} }
metadataMap["images"] = GameFieldMetadata(source = sourcePlugin) metadataMap["images"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
metadata.videoUrls?.takeIf { it.isNotEmpty() }?.let { videoUrls -> metadata.videoUrls?.takeIf { it.isNotEmpty() }?.let { videoUrls ->
if (!metadataMap.containsKey("videoUrls")) { if (!metadataMap.containsKey("videoUrls")) {
mergedGame.videoUrls = videoUrls.toList() mergedGame.videoUrls = videoUrls.toList()
metadataMap["videoUrls"] = GameFieldMetadata(source = sourcePlugin) metadataMap["videoUrls"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
} }
} }
} }
@@ -318,10 +338,33 @@ class GameService(
fun Game.toDto(): GameDto { fun Game.toDto(): GameDto {
// Helper functions // Helper functions
fun toDto(fieldMetadata: GameFieldMetadata): GameFieldMetadataDto { fun toDto(fieldMetadata: GameFieldMetadata): GameFieldMetadataDto {
return GameFieldMetadataDto( val source = fieldMetadata.source
source = fieldMetadata.source.pluginId,
updatedAt = fieldMetadata.updatedAt!! return when (source) {
) is GameFieldPluginSource -> {
GameFieldMetadataDto(
type = GameFieldMetadataType.PLUGIN,
source = source.plugin.pluginId,
updatedAt = fieldMetadata.updatedAt!!
)
}
is GameFieldUserSource -> {
GameFieldMetadataDto(
type = GameFieldMetadataType.USER,
source = source.user.id!!,
updatedAt = fieldMetadata.updatedAt!!
)
}
else -> {
GameFieldMetadataDto(
type = GameFieldMetadataType.UNKNOWN,
source = "unknown source",
updatedAt = fieldMetadata.updatedAt!!
)
}
}
} }
fun toDto(metadata: GameMetadata): GameMetadataDto { fun toDto(metadata: GameMetadata): GameMetadataDto {
@@ -330,7 +373,8 @@ fun Game.toDto(): GameDto {
downloadCount = metadata.downloadCount, downloadCount = metadata.downloadCount,
path = metadata.path, path = metadata.path,
fields = metadata.fields.mapValues { toDto(it.value) }, fields = metadata.fields.mapValues { toDto(it.value) },
originalIds = metadata.originalIds.mapKeys { it.key.pluginId } originalIds = metadata.originalIds.mapKeys { it.key.pluginId },
matchConfirmed = metadata.matchConfirmed
) )
} }
@@ -1,8 +1,16 @@
package de.grimsi.gameyfin.games.dto package de.grimsi.gameyfin.games.dto
import java.io.Serializable
import java.time.Instant import java.time.Instant
class GameFieldMetadataDto( class GameFieldMetadataDto(
val source: String, val type: GameFieldMetadataType,
val source: Serializable,
val updatedAt: Instant val updatedAt: Instant
) )
enum class GameFieldMetadataType {
PLUGIN,
USER,
UNKNOWN
}
@@ -8,5 +8,6 @@ class GameMetadataDto(
val fileSize: Long, val fileSize: Long,
val fields: Map<String, GameFieldMetadataDto>?, val fields: Map<String, GameFieldMetadataDto>?,
val originalIds: Map<String, String>?, val originalIds: Map<String, String>?,
val downloadCount: Int val downloadCount: Int,
val matchConfirmed: Boolean
) )
@@ -5,4 +5,5 @@ data class GameUpdateDto(
val title: String?, val title: String?,
val comment: String?, val comment: String?,
val summary: String?, val summary: String?,
val metadata: GameUpdateMetadataDto?
) )
@@ -0,0 +1,5 @@
package de.grimsi.gameyfin.games.dto
data class GameUpdateMetadataDto(
val matchConfirmed: Boolean?
)
@@ -78,6 +78,8 @@ class Game(
@Embedded @Embedded
var metadata: GameMetadata var metadata: GameMetadata
) { ) {
constructor(path: Path, library: Library) : this(library = library, metadata = GameMetadata(path = path.toString())) constructor(path: Path, library: Library) : this(library = library, metadata = GameMetadata(path = path.toString()))
} }
@@ -1,6 +1,7 @@
package de.grimsi.gameyfin.games.entities package de.grimsi.gameyfin.games.entities
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import de.grimsi.gameyfin.users.entities.User
import jakarta.persistence.* import jakarta.persistence.*
import org.hibernate.annotations.UpdateTimestamp import org.hibernate.annotations.UpdateTimestamp
import java.time.Instant import java.time.Instant
@@ -11,9 +12,29 @@ class GameFieldMetadata(
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null, var id: Long? = null,
@ManyToOne @OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.EAGER)
val source: PluginManagementEntry, val source: GameFieldSource,
@UpdateTimestamp @UpdateTimestamp
var updatedAt: Instant? = Instant.now() var updatedAt: Instant? = Instant.now()
) )
@Entity
@Inheritance
abstract class GameFieldSource(
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
open var id: Long? = null
)
@Entity
class GameFieldPluginSource(
@ManyToOne
val plugin: PluginManagementEntry
) : GameFieldSource()
@Entity
class GameFieldUserSource(
@ManyToOne
val user: User
) : GameFieldSource()
@@ -16,5 +16,7 @@ class GameMetadata(
@ElementCollection @ElementCollection
var originalIds: Map<PluginManagementEntry, String> = emptyMap(), var originalIds: Map<PluginManagementEntry, String> = emptyMap(),
var downloadCount: Int = 0 var downloadCount: Int = 0,
var matchConfirmed: Boolean = false
) )
@@ -3,6 +3,9 @@ package de.grimsi.gameyfin.libraries
import de.grimsi.gameyfin.games.entities.Game import de.grimsi.gameyfin.games.entities.Game
import de.grimsi.gameyfin.games.entities.LibraryEntityListener import de.grimsi.gameyfin.games.entities.LibraryEntityListener
import jakarta.persistence.* import jakarta.persistence.*
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.UpdateTimestamp
import java.time.Instant
@Entity @Entity
@EntityListeners(LibraryEntityListener::class) @EntityListeners(LibraryEntityListener::class)
@@ -11,6 +14,13 @@ class Library(
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null, var id: Long? = null,
@CreationTimestamp
@Column(updatable = false)
var createdAt: Instant? = null,
@UpdateTimestamp
var updatedAt: Instant? = null,
var name: String, var name: String,
@OneToMany(fetch = FetchType.EAGER, orphanRemoval = true, cascade = [CascadeType.ALL]) @OneToMany(fetch = FetchType.EAGER, orphanRemoval = true, cascade = [CascadeType.ALL])
@@ -20,5 +30,5 @@ class Library(
var games: MutableList<Game> = ArrayList(), var games: MutableList<Game> = ArrayList(),
@ElementCollection(fetch = FetchType.EAGER) @ElementCollection(fetch = FetchType.EAGER)
var unmatchedPaths: MutableList<String> = ArrayList() var unmatchedPaths: MutableList<String> = ArrayList(),
) )
@@ -37,7 +37,8 @@ class LibraryEndpoint(
libraryService.triggerScan(scanType, libraries) libraryService.triggerScan(scanType, libraries)
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun createLibrary(library: LibraryDto) = libraryService.create(library) fun createLibrary(library: LibraryDto, scanAfterCreation: Boolean = true) =
libraryService.create(library, scanAfterCreation)
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun updateLibrary(library: LibraryUpdateDto) = libraryService.update(library) fun updateLibrary(library: LibraryUpdateDto) = libraryService.update(library)
@@ -11,6 +11,7 @@ import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
import java.time.Instant
import java.util.concurrent.Callable import java.util.concurrent.Callable
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -85,8 +86,12 @@ class LibraryService(
* @param library: The library to create or update. * @param library: The library to create or update.
* @return The created or updated LibraryDto object. * @return The created or updated LibraryDto object.
*/ */
fun create(library: LibraryDto) { fun create(library: LibraryDto, scanAfterCreation: Boolean) {
libraryRepository.save(toEntity(library)) val newLibrary = libraryRepository.save(toEntity(library))
if (scanAfterCreation) {
triggerScanSingleLibrary(ScanType.QUICK, newLibrary)
}
} }
/** /**
@@ -97,19 +102,24 @@ class LibraryService(
* @throws IllegalArgumentException if the library ID is null or the library is not found. * @throws IllegalArgumentException if the library ID is null or the library is not found.
*/ */
fun update(libraryUpdateDto: LibraryUpdateDto) { fun update(libraryUpdateDto: LibraryUpdateDto) {
val existingLibrary = libraryRepository.findByIdOrNull(libraryUpdateDto.id) var library = libraryRepository.findByIdOrNull(libraryUpdateDto.id)
?: throw IllegalArgumentException("Library with ID $libraryUpdateDto.id not found") ?: throw IllegalArgumentException("Library with ID $libraryUpdateDto.id not found")
// Update only non-null fields // Update only non-null fields
libraryUpdateDto.name?.let { existingLibrary.name = it } libraryUpdateDto.name?.let { library.name = it }
libraryUpdateDto.directories?.let { libraryUpdateDto.directories?.let {
existingLibrary.directories.clear() library.directories.clear()
existingLibrary.directories.addAll( library.directories.addAll(
it.map { d -> DirectoryMapping(internalPath = d.internalPath, externalPath = d.externalPath) } it.map { d -> DirectoryMapping(internalPath = d.internalPath, externalPath = d.externalPath) }
) )
} }
libraryUpdateDto.unmatchedPaths?.let {
library.unmatchedPaths.clear()
library.unmatchedPaths.addAll(it)
}
libraryRepository.save(existingLibrary) library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp
libraryRepository.save(library)
} }
/** /**
@@ -121,6 +131,17 @@ class LibraryService(
libraryRepository.deleteById(libraryId) libraryRepository.deleteById(libraryId)
} }
fun deleteGameFromLibrary(gameId: Long) {
val game = gameService.getById(gameId)
var library = game.library
library.games.removeIf { it.id == gameId }
library.unmatchedPaths.add(game.metadata.path)
library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp
libraryRepository.save(library)
}
/** /**
* Wrapper function to trigger a scan for a list of libraries. * Wrapper function to trigger a scan for a list of libraries.
*/ */
@@ -133,6 +154,10 @@ class LibraryService(
} }
} }
fun triggerScanSingleLibrary(scanType: ScanType, library: Library) {
triggerScan(scanType, listOf(library.toDto()))
}
/** /**
* Triggers a quick scan for a list of libraries. * Triggers a quick scan for a list of libraries.
* A quick scan will only scan for new games and deleted games, but will not touch existing games. * A quick scan will only scan for new games and deleted games, but will not touch existing games.
@@ -287,6 +312,7 @@ class LibraryService(
addGamesToLibrary(persistedGames, library) addGamesToLibrary(persistedGames, library)
// 6. Persist library // 6. Persist library
library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp
libraryRepository.save(library) libraryRepository.save(library)
progress.currentStep = LibraryScanStep(description = "Finished") progress.currentStep = LibraryScanStep(description = "Finished")
@@ -340,6 +366,7 @@ fun Library.toDto(): LibraryDto {
name = this.name, name = this.name,
directories = this.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) }, directories = this.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
games = this.games.mapNotNull { it.id }, games = this.games.mapNotNull { it.id },
stats = statsDto stats = statsDto,
unmatchedPaths = this.unmatchedPaths
) )
} }
@@ -5,5 +5,6 @@ data class LibraryDto(
val name: String, val name: String,
val directories: List<DirectoryMappingDto>, val directories: List<DirectoryMappingDto>,
val games: List<Long>?, val games: List<Long>?,
val stats: LibraryStatsDto? val stats: LibraryStatsDto?,
val unmatchedPaths: List<String>? = emptyList()
) )
@@ -4,4 +4,5 @@ data class LibraryUpdateDto(
val id: Long, val id: Long,
val name: String? = null, val name: String? = null,
val directories: List<DirectoryMappingDto>? = null, val directories: List<DirectoryMappingDto>? = null,
val unmatchedPaths: List<String>? = null
) )