mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
(WIP) Implement game management
This commit is contained in:
Generated
+800
-800
File diff suppressed because it is too large
Load Diff
+54
-54
@@ -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>
|
||||
)}
|
||||
|
||||
+81
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user