(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",
"@react-stately/data": "^3.12.2",
"@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/hilla-file-router": "24.8.0-beta2",
"@vaadin/hilla-frontend": "24.8.0-beta2",
"@vaadin/hilla-lit-form": "24.8.0-beta2",
"@vaadin/hilla-react-auth": "24.8.0-beta2",
"@vaadin/hilla-react-crud": "24.8.0-beta2",
"@vaadin/hilla-react-form": "24.8.0-beta2",
"@vaadin/hilla-react-i18n": "24.8.0-beta2",
"@vaadin/hilla-react-signals": "24.8.0-beta2",
"@vaadin/polymer-legacy-adapter": "24.8.0-beta1",
"@vaadin/react-components": "24.8.0-beta1",
"@vaadin/hilla-file-router": "24.8.0-rc1",
"@vaadin/hilla-frontend": "24.8.0-rc1",
"@vaadin/hilla-lit-form": "24.8.0-rc1",
"@vaadin/hilla-react-auth": "24.8.0-rc1",
"@vaadin/hilla-react-crud": "24.8.0-rc1",
"@vaadin/hilla-react-form": "24.8.0-rc1",
"@vaadin/hilla-react-i18n": "24.8.0-rc1",
"@vaadin/hilla-react-signals": "24.8.0-rc1",
"@vaadin/polymer-legacy-adapter": "24.8.0-rc1",
"@vaadin/react-components": "24.8.0-rc1",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.8.0-beta1",
"@vaadin/vaadin-material-styles": "24.8.0-beta1",
"@vaadin/vaadin-themable-mixin": "24.8.0-beta1",
"@vaadin/vaadin-lumo-styles": "24.8.0-rc1",
"@vaadin/vaadin-material-styles": "24.8.0-rc1",
"@vaadin/vaadin-themable-mixin": "24.8.0-rc1",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"classnames": "^2.5.1",
"construct-style-sheets-polyfill": "3.1.0",
@@ -60,19 +60,19 @@
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@types/node": "^22.4.0",
"@types/react": "18.3.22",
"@types/react": "18.3.23",
"@types/react-dom": "18.3.7",
"@vaadin/hilla-generator-cli": "24.8.0-beta2",
"@vaadin/hilla-generator-core": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-backbone": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-barrel": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-client": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-model": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-push": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-signals": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.0-beta2",
"@vaadin/hilla-generator-utils": "24.8.0-beta2",
"@vaadin/hilla-generator-cli": "24.8.0-rc1",
"@vaadin/hilla-generator-core": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-backbone": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-barrel": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-client": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-model": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-push": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-signals": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.0-rc1",
"@vaadin/hilla-generator-utils": "24.8.0-rc1",
"@vitejs/plugin-react": "4.5.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"async": "3.2.6",
@@ -148,22 +148,22 @@
"vaadin": {
"dependencies": {
"@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.8.0-beta1",
"@vaadin/bundles": "24.8.0-rc1",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.8.0-beta2",
"@vaadin/hilla-frontend": "24.8.0-beta2",
"@vaadin/hilla-lit-form": "24.8.0-beta2",
"@vaadin/hilla-react-auth": "24.8.0-beta2",
"@vaadin/hilla-react-crud": "24.8.0-beta2",
"@vaadin/hilla-react-form": "24.8.0-beta2",
"@vaadin/hilla-react-i18n": "24.8.0-beta2",
"@vaadin/hilla-react-signals": "24.8.0-beta2",
"@vaadin/polymer-legacy-adapter": "24.8.0-beta1",
"@vaadin/react-components": "24.8.0-beta1",
"@vaadin/hilla-file-router": "24.8.0-rc1",
"@vaadin/hilla-frontend": "24.8.0-rc1",
"@vaadin/hilla-lit-form": "24.8.0-rc1",
"@vaadin/hilla-react-auth": "24.8.0-rc1",
"@vaadin/hilla-react-crud": "24.8.0-rc1",
"@vaadin/hilla-react-form": "24.8.0-rc1",
"@vaadin/hilla-react-i18n": "24.8.0-rc1",
"@vaadin/hilla-react-signals": "24.8.0-rc1",
"@vaadin/polymer-legacy-adapter": "24.8.0-rc1",
"@vaadin/react-components": "24.8.0-rc1",
"@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.8.0-beta1",
"@vaadin/vaadin-material-styles": "24.8.0-beta1",
"@vaadin/vaadin-themable-mixin": "24.8.0-beta1",
"@vaadin/vaadin-lumo-styles": "24.8.0-rc1",
"@vaadin/vaadin-material-styles": "24.8.0-rc1",
"@vaadin/vaadin-themable-mixin": "24.8.0-rc1",
"@vaadin/vaadin-usage-statistics": "2.1.3",
"construct-style-sheets-polyfill": "3.1.0",
"date-fns": "2.29.3",
@@ -177,19 +177,19 @@
"@preact/signals-react-transform": "0.5.1",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@types/react": "18.3.22",
"@types/react": "18.3.23",
"@types/react-dom": "18.3.7",
"@vaadin/hilla-generator-cli": "24.8.0-beta2",
"@vaadin/hilla-generator-core": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-backbone": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-barrel": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-client": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-model": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-push": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-signals": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.0-beta2",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.0-beta2",
"@vaadin/hilla-generator-utils": "24.8.0-beta2",
"@vaadin/hilla-generator-cli": "24.8.0-rc1",
"@vaadin/hilla-generator-core": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-backbone": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-barrel": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-client": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-model": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-push": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-signals": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-subtypes": "24.8.0-rc1",
"@vaadin/hilla-generator-plugin-transfertypes": "24.8.0-rc1",
"@vaadin/hilla-generator-utils": "24.8.0-rc1",
"@vitejs/plugin-react": "4.5.0",
"async": "3.2.6",
"glob": "11.0.2",
@@ -205,6 +205,6 @@
"workbox-core": "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 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 {useSnapshot} from "valtio/react";
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 {
library: LibraryDto;
}
export default function LibraryManagementGames({library}: LibraryManagementGamesProps) {
const rowsPerPage = 25;
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">
<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>
<TableColumn allowsSorting>Game</TableColumn>
<TableColumn allowsSorting>Added to library</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>
<TableBody emptyContent="This library is empty." items={games}>
<TableBody emptyContent="Your filter did not match any games." items={items}>
{(item) => (
<TableRow key={item.id}>
<TableCell>
@@ -35,9 +115,18 @@ export default function LibraryManagementGames({library}: LibraryManagementGames
{item.metadata.path}
</TableCell>
<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} color="danger"><Trash/></Button>
<Button isIconOnly size="sm" color="danger"
onPress={() => deleteGame(item)}><Trash/></Button>
</TableCell>
</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 {addToast, Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import React, {useState} from "react";
import {addToast, Button, Checkbox, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
@@ -20,23 +20,25 @@ export default function LibraryCreationModal({
onOpenChange
}: LibraryCreationModalProps) {
const [scanAfterCreation, setScanAfterCreation] = useState<boolean>(true);
async function createLibrary(library: LibraryDto) {
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) {
addToast({
title: "Error creating library",
description: `Library ${library.name} could not be created!`,
color: "warning"
});
return;
throw "Error creating library: " + e;
}
addToast({
title: "New library created",
description: `Library ${library.name} created!`,
color: "success"
});
}
return (
@@ -74,17 +76,21 @@ export default function LibraryCreationModal({
<DirectoryMappingInput name="directories"/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
<ModalFooter className="flex flex-row justify-between">
<Checkbox isSelected={scanAfterCreation} onValueChange={setScanAfterCreation}>Scan
after creation?</Checkbox>
<div className="flex flex-row">
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isLoading={formik.isSubmitting}
isDisabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Add"}
</Button>
</div>
</ModalFooter>
</Form>
}
+11
View File
@@ -18,6 +18,17 @@ export function camelCaseToTitle(text: string): string {
.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) {
switch (role) {
case "ROLE_SUPERADMIN":
@@ -7,6 +7,7 @@ import LibraryManagementDetails from "Frontend/components/general/library/Librar
import LibraryManagementGames from "Frontend/components/general/library/LibraryManagementGames";
import {useSnapshot} from "valtio/react";
import {initializeLibraryState, libraryState} from "Frontend/state/LibraryState";
import LibraryManagementUnmatchedPaths from "Frontend/components/general/library/LibraryManagementUnmatchedPaths";
export default function LibraryManagementView() {
@@ -41,7 +42,8 @@ export default function LibraryManagementView() {
<LibraryManagementGames library={state.state[libraryId]}/>
</Tab>
<Tab title="Unmatched paths">
<p>Unmatched paths</p>
{/* @ts-ignore */}
<LibraryManagementUnmatchedPaths library={state.state[libraryId]}/>
</Tab>
</Tabs>
</div>;
@@ -5,6 +5,7 @@ import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.games.dto.GameDto
import de.grimsi.gameyfin.games.dto.GameEvent
import de.grimsi.gameyfin.games.dto.GameUpdateDto
import de.grimsi.gameyfin.libraries.LibraryService
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
import reactor.core.publisher.Flux
@@ -12,7 +13,8 @@ import reactor.core.publisher.Flux
@Endpoint
@PermitAll
class GameEndpoint(
private val gameService: GameService
private val gameService: GameService,
private val libraryService: LibraryService
) {
fun subscribe(): Flux<List<GameEvent>> {
return GameService.subscribe()
@@ -24,5 +26,8 @@ class GameEndpoint(
fun updateGame(game: GameUpdateDto) = gameService.update(game)
@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.comment?.let { existingGame.comment = it }
gameUpdateDto.summary?.let { existingGame.summary = it }
gameUpdateDto.metadata?.let { metadata ->
metadata.matchConfirmed?.let { existingGame.metadata.matchConfirmed = it }
}
gameRepository.save(existingGame)
}
@@ -129,6 +132,12 @@ class GameService(
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
* Runs the queries concurrently and asynchronously
@@ -206,81 +215,91 @@ class GameService(
metadata.title.takeIf { it.isNotBlank() }?.let { title ->
if (!metadataMap.containsKey("title")) {
mergedGame.title = title
metadataMap["title"] = GameFieldMetadata(source = sourcePlugin)
metadataMap["title"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
}
}
metadata.description?.takeIf { it.isNotBlank() }?.let { description ->
if (!metadataMap.containsKey("summary")) {
mergedGame.summary = description
metadataMap["summary"] = GameFieldMetadata(source = sourcePlugin)
metadataMap["summary"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
}
}
metadata.coverUrl?.let { coverUrl ->
if (!metadataMap.containsKey("coverImage")) {
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 ->
if (!metadataMap.containsKey("release")) {
mergedGame.release = release
metadataMap["release"] = GameFieldMetadata(source = sourcePlugin)
metadataMap["release"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
}
}
metadata.userRating?.let { userRating ->
if (!metadataMap.containsKey("userRating")) {
mergedGame.userRating = userRating
metadataMap["userRating"] = GameFieldMetadata(source = sourcePlugin)
metadataMap["userRating"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
}
}
metadata.criticRating?.let { criticRating ->
if (!metadataMap.containsKey("criticRating")) {
mergedGame.criticRating = criticRating
metadataMap["criticRating"] = GameFieldMetadata(source = sourcePlugin)
metadataMap["criticRating"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
}
}
metadata.publishedBy?.takeIf { it.isNotEmpty() }?.let { publishedBy ->
if (!metadataMap.containsKey("publishers")) {
mergedGame.publishers =
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 ->
if (!metadataMap.containsKey("developers")) {
mergedGame.developers =
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 ->
if (!metadataMap.containsKey("genres")) {
mergedGame.genres = genres.toList()
metadataMap["genres"] = GameFieldMetadata(source = sourcePlugin)
metadataMap["genres"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
}
}
metadata.themes?.takeIf { it.isNotEmpty() }?.let { themes ->
if (!metadataMap.containsKey("themes")) {
mergedGame.themes = themes.toList()
metadataMap["themes"] = GameFieldMetadata(source = sourcePlugin)
metadataMap["themes"] = GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
}
}
metadata.keywords?.takeIf { it.isNotEmpty() }?.let { keywords ->
if (!metadataMap.containsKey("keywords")) {
mergedGame.keywords = keywords.toList()
metadataMap["keywords"] = GameFieldMetadata(source = sourcePlugin)
metadataMap["keywords"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
}
}
metadata.features?.takeIf { it.isNotEmpty() }?.let { features ->
if (!metadataMap.containsKey("features")) {
mergedGame.features = features.toList()
metadataMap["features"] = GameFieldMetadata(source = sourcePlugin)
metadataMap["features"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
}
}
metadata.perspectives?.takeIf { it.isNotEmpty() }?.let { perspectives ->
if (!metadataMap.containsKey("perspectives")) {
mergedGame.perspectives = perspectives.toList()
metadataMap["perspectives"] = GameFieldMetadata(source = sourcePlugin)
metadataMap["perspectives"] =
GameFieldMetadata(source = GameFieldPluginSource(plugin = sourcePlugin))
}
}
metadata.screenshotUrls?.takeIf { it.isNotEmpty() }?.let { screenshotUrls ->
@@ -288,13 +307,14 @@ class GameService(
mergedGame.images = runBlocking {
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 ->
if (!metadataMap.containsKey("videoUrls")) {
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 {
// Helper functions
fun toDto(fieldMetadata: GameFieldMetadata): GameFieldMetadataDto {
return GameFieldMetadataDto(
source = fieldMetadata.source.pluginId,
updatedAt = fieldMetadata.updatedAt!!
)
val source = fieldMetadata.source
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 {
@@ -330,7 +373,8 @@ fun Game.toDto(): GameDto {
downloadCount = metadata.downloadCount,
path = metadata.path,
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
import java.io.Serializable
import java.time.Instant
class GameFieldMetadataDto(
val source: String,
val type: GameFieldMetadataType,
val source: Serializable,
val updatedAt: Instant
)
)
enum class GameFieldMetadataType {
PLUGIN,
USER,
UNKNOWN
}
@@ -8,5 +8,6 @@ class GameMetadataDto(
val fileSize: Long,
val fields: Map<String, GameFieldMetadataDto>?,
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 comment: 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
var metadata: GameMetadata
) {
constructor(path: Path, library: Library) : this(library = library, metadata = GameMetadata(path = path.toString()))
}
@@ -1,6 +1,7 @@
package de.grimsi.gameyfin.games.entities
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import de.grimsi.gameyfin.users.entities.User
import jakarta.persistence.*
import org.hibernate.annotations.UpdateTimestamp
import java.time.Instant
@@ -11,9 +12,29 @@ class GameFieldMetadata(
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
@ManyToOne
val source: PluginManagementEntry,
@OneToOne(cascade = [CascadeType.ALL], orphanRemoval = true, fetch = FetchType.EAGER)
val source: GameFieldSource,
@UpdateTimestamp
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
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.LibraryEntityListener
import jakarta.persistence.*
import org.hibernate.annotations.CreationTimestamp
import org.hibernate.annotations.UpdateTimestamp
import java.time.Instant
@Entity
@EntityListeners(LibraryEntityListener::class)
@@ -11,6 +14,13 @@ class Library(
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
@CreationTimestamp
@Column(updatable = false)
var createdAt: Instant? = null,
@UpdateTimestamp
var updatedAt: Instant? = null,
var name: String,
@OneToMany(fetch = FetchType.EAGER, orphanRemoval = true, cascade = [CascadeType.ALL])
@@ -20,5 +30,5 @@ class Library(
var games: MutableList<Game> = ArrayList(),
@ElementCollection(fetch = FetchType.EAGER)
var unmatchedPaths: MutableList<String> = ArrayList()
var unmatchedPaths: MutableList<String> = ArrayList(),
)
@@ -37,7 +37,8 @@ class LibraryEndpoint(
libraryService.triggerScan(scanType, libraries)
@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)
fun updateLibrary(library: LibraryUpdateDto) = libraryService.update(library)
@@ -11,6 +11,7 @@ import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import java.time.Instant
import java.util.concurrent.Callable
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
@@ -85,8 +86,12 @@ class LibraryService(
* @param library: The library to create or update.
* @return The created or updated LibraryDto object.
*/
fun create(library: LibraryDto) {
libraryRepository.save(toEntity(library))
fun create(library: LibraryDto, scanAfterCreation: Boolean) {
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.
*/
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")
// Update only non-null fields
libraryUpdateDto.name?.let { existingLibrary.name = it }
libraryUpdateDto.name?.let { library.name = it }
libraryUpdateDto.directories?.let {
existingLibrary.directories.clear()
existingLibrary.directories.addAll(
library.directories.clear()
library.directories.addAll(
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)
}
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.
*/
@@ -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.
* 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)
// 6. Persist library
library.updatedAt = Instant.now() // Force the EntityListener to trigger an update and update the timestamp
libraryRepository.save(library)
progress.currentStep = LibraryScanStep(description = "Finished")
@@ -340,6 +366,7 @@ fun Library.toDto(): LibraryDto {
name = this.name,
directories = this.directories.map { DirectoryMappingDto(it.internalPath, it.externalPath) },
games = this.games.mapNotNull { it.id },
stats = statsDto
stats = statsDto,
unmatchedPaths = this.unmatchedPaths
)
}
@@ -5,5 +5,6 @@ data class LibraryDto(
val name: String,
val directories: List<DirectoryMappingDto>,
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 name: String? = null,
val directories: List<DirectoryMappingDto>? = null,
val unmatchedPaths: List<String>? = null
)