Update dependenicies

Update Plugin-API to allow multiple results per plugin
Add LibraryDetailsModal
Minor refactorings & bugfixes
This commit is contained in:
grimsi
2025-05-01 22:07:55 +02:00
parent 410172fee2
commit 5c0e06c1d7
19 changed files with 1329 additions and 1985 deletions
+1009 -1823
View File
File diff suppressed because it is too large Load Diff
+67 -67
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.7.1", "@vaadin/bundles": "24.7.5",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.7.1", "@vaadin/hilla-file-router": "24.7.3",
"@vaadin/hilla-frontend": "24.7.1", "@vaadin/hilla-frontend": "24.7.3",
"@vaadin/hilla-lit-form": "24.7.1", "@vaadin/hilla-lit-form": "24.7.3",
"@vaadin/hilla-react-auth": "24.7.1", "@vaadin/hilla-react-auth": "24.7.3",
"@vaadin/hilla-react-crud": "24.7.1", "@vaadin/hilla-react-crud": "24.7.3",
"@vaadin/hilla-react-form": "24.7.1", "@vaadin/hilla-react-form": "24.7.3",
"@vaadin/hilla-react-i18n": "24.7.1", "@vaadin/hilla-react-i18n": "24.7.3",
"@vaadin/hilla-react-signals": "24.7.1", "@vaadin/hilla-react-signals": "24.7.3",
"@vaadin/polymer-legacy-adapter": "24.7.1", "@vaadin/polymer-legacy-adapter": "24.7.5",
"@vaadin/react-components": "24.7.1", "@vaadin/react-components": "24.7.5",
"@vaadin/vaadin-development-mode-detector": "2.0.7", "@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.7.1", "@vaadin/vaadin-lumo-styles": "24.7.5",
"@vaadin/vaadin-material-styles": "24.7.1", "@vaadin/vaadin-material-styles": "24.7.5",
"@vaadin/vaadin-themable-mixin": "24.7.1", "@vaadin/vaadin-themable-mixin": "24.7.5",
"@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",
@@ -33,7 +33,7 @@
"formik": "^2.4.6", "formik": "^2.4.6",
"framer-motion": "^12.5.0", "framer-motion": "^12.5.0",
"http-status-codes": "^2.3.0", "http-status-codes": "^2.3.0",
"lit": "3.2.1", "lit": "3.3.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"moment-timezone": "^0.5.47", "moment-timezone": "^0.5.47",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -43,7 +43,7 @@
"react-aria-components": "^1.7.1", "react-aria-components": "^1.7.1",
"react-confetti-boom": "^1.0.0", "react-confetti-boom": "^1.0.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-router": "7.2.0", "react-router": "7.5.2",
"yup": "^1.6.1" "yup": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
@@ -53,24 +53,24 @@
"@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.18", "@types/react": "18.3.20",
"@types/react-dom": "18.3.5", "@types/react-dom": "18.3.6",
"@vaadin/hilla-generator-cli": "24.7.1", "@vaadin/hilla-generator-cli": "24.7.3",
"@vaadin/hilla-generator-core": "24.7.1", "@vaadin/hilla-generator-core": "24.7.3",
"@vaadin/hilla-generator-plugin-backbone": "24.7.1", "@vaadin/hilla-generator-plugin-backbone": "24.7.3",
"@vaadin/hilla-generator-plugin-barrel": "24.7.1", "@vaadin/hilla-generator-plugin-barrel": "24.7.3",
"@vaadin/hilla-generator-plugin-client": "24.7.1", "@vaadin/hilla-generator-plugin-client": "24.7.3",
"@vaadin/hilla-generator-plugin-model": "24.7.1", "@vaadin/hilla-generator-plugin-model": "24.7.3",
"@vaadin/hilla-generator-plugin-push": "24.7.1", "@vaadin/hilla-generator-plugin-push": "24.7.3",
"@vaadin/hilla-generator-plugin-signals": "24.7.1", "@vaadin/hilla-generator-plugin-signals": "24.7.3",
"@vaadin/hilla-generator-plugin-subtypes": "24.7.1", "@vaadin/hilla-generator-plugin-subtypes": "24.7.3",
"@vaadin/hilla-generator-plugin-transfertypes": "24.7.1", "@vaadin/hilla-generator-plugin-transfertypes": "24.7.3",
"@vaadin/hilla-generator-utils": "24.7.1", "@vaadin/hilla-generator-utils": "24.7.3",
"@vitejs/plugin-react": "4.3.4", "@vitejs/plugin-react": "4.4.1",
"@vitejs/plugin-react-swc": "^3.7.0", "@vitejs/plugin-react-swc": "^3.7.0",
"async": "3.2.6", "async": "3.2.6",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"glob": "11.0.1", "glob": "11.0.2",
"postcss": "^8.4.41", "postcss": "^8.4.41",
"postcss-import": "^16.1.0", "postcss-import": "^16.1.0",
"rollup-plugin-brotli": "3.1.0", "rollup-plugin-brotli": "3.1.0",
@@ -79,8 +79,8 @@
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"transform-ast": "2.4.4", "transform-ast": "2.4.4",
"typescript": "5.7.3", "typescript": "5.7.3",
"vite": "6.2.3", "vite": "6.3.3",
"vite-plugin-checker": "0.8.0", "vite-plugin-checker": "0.9.1",
"workbox-build": "7.3.0", "workbox-build": "7.3.0",
"workbox-core": "7.3.0", "workbox-core": "7.3.0",
"workbox-precaching": "7.3.0" "workbox-precaching": "7.3.0"
@@ -133,62 +133,62 @@
"vaadin": { "vaadin": {
"dependencies": { "dependencies": {
"@polymer/polymer": "3.5.2", "@polymer/polymer": "3.5.2",
"@vaadin/bundles": "24.7.1", "@vaadin/bundles": "24.7.5",
"@vaadin/common-frontend": "0.0.19", "@vaadin/common-frontend": "0.0.19",
"@vaadin/hilla-file-router": "24.7.1", "@vaadin/hilla-file-router": "24.7.3",
"@vaadin/hilla-frontend": "24.7.1", "@vaadin/hilla-frontend": "24.7.3",
"@vaadin/hilla-lit-form": "24.7.1", "@vaadin/hilla-lit-form": "24.7.3",
"@vaadin/hilla-react-auth": "24.7.1", "@vaadin/hilla-react-auth": "24.7.3",
"@vaadin/hilla-react-crud": "24.7.1", "@vaadin/hilla-react-crud": "24.7.3",
"@vaadin/hilla-react-form": "24.7.1", "@vaadin/hilla-react-form": "24.7.3",
"@vaadin/hilla-react-i18n": "24.7.1", "@vaadin/hilla-react-i18n": "24.7.3",
"@vaadin/hilla-react-signals": "24.7.1", "@vaadin/hilla-react-signals": "24.7.3",
"@vaadin/polymer-legacy-adapter": "24.7.1", "@vaadin/polymer-legacy-adapter": "24.7.5",
"@vaadin/react-components": "24.7.1", "@vaadin/react-components": "24.7.5",
"@vaadin/vaadin-development-mode-detector": "2.0.7", "@vaadin/vaadin-development-mode-detector": "2.0.7",
"@vaadin/vaadin-lumo-styles": "24.7.1", "@vaadin/vaadin-lumo-styles": "24.7.5",
"@vaadin/vaadin-material-styles": "24.7.1", "@vaadin/vaadin-material-styles": "24.7.5",
"@vaadin/vaadin-themable-mixin": "24.7.1", "@vaadin/vaadin-themable-mixin": "24.7.5",
"@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",
"lit": "3.2.1", "lit": "3.3.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-router": "7.2.0" "react-router": "7.5.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/preset-react": "7.26.3", "@babel/preset-react": "7.26.3",
"@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.18", "@types/react": "18.3.20",
"@types/react-dom": "18.3.5", "@types/react-dom": "18.3.6",
"@vaadin/hilla-generator-cli": "24.7.1", "@vaadin/hilla-generator-cli": "24.7.3",
"@vaadin/hilla-generator-core": "24.7.1", "@vaadin/hilla-generator-core": "24.7.3",
"@vaadin/hilla-generator-plugin-backbone": "24.7.1", "@vaadin/hilla-generator-plugin-backbone": "24.7.3",
"@vaadin/hilla-generator-plugin-barrel": "24.7.1", "@vaadin/hilla-generator-plugin-barrel": "24.7.3",
"@vaadin/hilla-generator-plugin-client": "24.7.1", "@vaadin/hilla-generator-plugin-client": "24.7.3",
"@vaadin/hilla-generator-plugin-model": "24.7.1", "@vaadin/hilla-generator-plugin-model": "24.7.3",
"@vaadin/hilla-generator-plugin-push": "24.7.1", "@vaadin/hilla-generator-plugin-push": "24.7.3",
"@vaadin/hilla-generator-plugin-signals": "24.7.1", "@vaadin/hilla-generator-plugin-signals": "24.7.3",
"@vaadin/hilla-generator-plugin-subtypes": "24.7.1", "@vaadin/hilla-generator-plugin-subtypes": "24.7.3",
"@vaadin/hilla-generator-plugin-transfertypes": "24.7.1", "@vaadin/hilla-generator-plugin-transfertypes": "24.7.3",
"@vaadin/hilla-generator-utils": "24.7.1", "@vaadin/hilla-generator-utils": "24.7.3",
"@vitejs/plugin-react": "4.3.4", "@vitejs/plugin-react": "4.4.1",
"async": "3.2.6", "async": "3.2.6",
"glob": "11.0.1", "glob": "11.0.2",
"rollup-plugin-brotli": "3.1.0", "rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.14.0", "rollup-plugin-visualizer": "5.14.0",
"strip-css-comments": "5.0.0", "strip-css-comments": "5.0.0",
"transform-ast": "2.4.4", "transform-ast": "2.4.4",
"typescript": "5.7.3", "typescript": "5.7.3",
"vite": "6.2.3", "vite": "6.3.3",
"vite-plugin-checker": "0.8.0", "vite-plugin-checker": "0.9.1",
"workbox-build": "7.3.0", "workbox-build": "7.3.0",
"workbox-core": "7.3.0", "workbox-core": "7.3.0",
"workbox-precaching": "7.3.0" "workbox-precaching": "7.3.0"
}, },
"hash": "26d6e466339c0b4c7dfffb8fe616e725dd60e800bba0169742bfabede9023961" "hash": "4507ade910f8d37b606e1a8cff078e31d3a90314f1c7426a4aea761dc563f02a"
} }
} }
@@ -6,9 +6,10 @@ import * as Yup from 'yup';
import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react"; import {Button, Divider, Tooltip, useDisclosure} from "@heroui/react";
import {Plus} from "@phosphor-icons/react"; import {Plus} from "@phosphor-icons/react";
import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {LibraryEndpoint} from "Frontend/generated/endpoints";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/LibraryDto";
import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard"; import {LibraryOverviewCard} from "Frontend/components/general/cards/LibraryOverviewCard";
import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal"; import LibraryCreationModal from "Frontend/components/general/modals/LibraryCreationModal";
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
function LibraryManagementLayout({getConfig, formik}: any) { function LibraryManagementLayout({getConfig, formik}: any) {
const [libraries, setLibraries] = useState<LibraryDto[]>([]); const [libraries, setLibraries] = useState<LibraryDto[]>([]);
@@ -27,6 +28,21 @@ function LibraryManagementLayout({getConfig, formik}: any) {
}); });
}, []); }, []);
async function updateLibrary(library: LibraryUpdateDto) {
let updatedLibrary = await LibraryEndpoint.updateLibrary(library);
if (updatedLibrary === undefined) return;
setLibraries((prevLibraries) => {
const index = prevLibraries.findIndex((l) => l.id === updatedLibrary.id);
if (index !== -1) {
const updatedLibraries = [...prevLibraries];
updatedLibraries[index] = updatedLibrary;
return updatedLibraries;
}
return [...prevLibraries, updatedLibrary];
});
}
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<Section title="Permissions"/> <Section title="Permissions"/>
@@ -54,7 +70,9 @@ function LibraryManagementLayout({getConfig, formik}: any) {
{libraries.length > 0 ? {libraries.length > 0 ?
// Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px // Aspect ratio of cover = 12/17 -> 5 covers = 60/17 -> 353px * 100px
<div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]"> <div id="library-cards" className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(353px,1fr))]">
{libraries.map((library) => <LibraryOverviewCard library={library} key={library.name}/>)} {libraries.map((library) =>
<LibraryOverviewCard library={library} updateLibrary={updateLibrary} key={library.name}/>
)}
</div> : </div> :
"No libraries configured. Add your first library!" "No libraries configured. Add your first library!"
} }
@@ -1,5 +1,5 @@
import {Button, Card, Chip, Tooltip} from "@heroui/react"; import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/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 React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {LibraryEndpoint} from "Frontend/generated/endpoints";
@@ -14,18 +14,25 @@ import {
Lego, Lego,
MagnifyingGlass, MagnifyingGlass,
Skull, Skull,
SlidersHorizontal,
SoccerBall, SoccerBall,
Strategy, Strategy,
Sword, Sword,
TreasureChest, TreasureChest,
Trophy Trophy
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import LibraryDetailsModal from "Frontend/components/general/modals/LibraryDetailsModal";
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
export function LibraryOverviewCard({library}: { library: LibraryDto }) { export function LibraryOverviewCard({library, updateLibrary}: {
library: LibraryDto,
updateLibrary: (library: LibraryUpdateDto) => void
}) {
const MAX_COVER_COUNT = 5; const MAX_COVER_COUNT = 5;
const rand = new Rand(library.id.toString()); const rand = new Rand(library.id.toString());
const [randomGamesFromLibrary, setRandomGamesFromLibrary] = useState<GameDto[]>([]); const [randomGamesFromLibrary, setRandomGamesFromLibrary] = useState<GameDto[]>([]);
const libraryDetailsModal = useDisclosure();
useEffect(() => { useEffect(() => {
LibraryEndpoint.getGamesInLibrary(library.id).then( LibraryEndpoint.getGamesInLibrary(library.id).then(
@@ -35,6 +42,7 @@ export function LibraryOverviewCard({library}: { library: LibraryDto }) {
let gamesFromLibrary: GameDto[] = response let gamesFromLibrary: GameDto[] = response
.filter(g => !!g) .filter(g => !!g)
.sort((a: GameDto, b: GameDto) => a.id - b.id)
.sort(() => rand.next() - 0.5) .sort(() => rand.next() - 0.5)
.slice(0, count) .slice(0, count)
@@ -44,55 +52,68 @@ export function LibraryOverviewCard({library}: { library: LibraryDto }) {
}, []); }, []);
return ( return (
<Card className="flex flex-col justify-between w-[353px]"> <>
<div className="flex flex-1 justify-center items-center"> <Card className="flex flex-col justify-between w-[353px]">
<div className="flex flex-1 opacity-10 min-h-[100px]"> <div className="flex flex-1 justify-center items-center">
<div className="absolute w-full h-full opacity-50"> <div className="flex flex-1 opacity-10 min-h-[100px]">
<GameController size={32} <div className="absolute w-full h-full opacity-50">
className="absolute fill-primary top-[10%] left-[10%] rotate-[350deg]"/> <GameController size={32}
<SoccerBall size={34} className="absolute fill-primary top-[10%] left-[10%] rotate-[350deg]"/>
className="absolute fill-primary top-[50%] left-[35%] rotate-[60deg]"/> <SoccerBall size={34}
<Joystick size={40} className="absolute top-[30%] left-[50%] rotate-[90deg]"/> className="absolute fill-primary top-[50%] left-[35%] rotate-[60deg]"/>
<Strategy size={36} className="absolute fill-primary top-[50%] left-[70%] rotate-[30deg]"/> <Joystick size={40} className="absolute top-[30%] left-[50%] rotate-[90deg]"/>
<Sword size={28} className="absolute top-[70%] left-[10%] rotate-[60deg]"/> <Strategy size={36} className="absolute fill-primary top-[50%] left-[70%] rotate-[30deg]"/>
<Alien size={34} className="absolute fill-primary top-[10%] left-[85%] rotate-[15deg]"/> <Sword size={28} className="absolute top-[70%] left-[10%] rotate-[60deg]"/>
<CastleTurret size={30} className="absolute top-[5%] left-[40%] rotate-[320deg]"/> <Alien size={34} className="absolute fill-primary top-[10%] left-[85%] rotate-[15deg]"/>
<Ghost size={38} className="absolute fill-primary top-[40%] left-[5%] rotate-[300deg]"/> <CastleTurret size={30} className="absolute top-[5%] left-[40%] rotate-[320deg]"/>
<Skull size={32} className="absolute top-[80%] left-[30%] rotate-[90deg]"/> <Ghost size={38} className="absolute fill-primary top-[40%] left-[5%] rotate-[300deg]"/>
<Trophy size={36} className="absolute fill-primary top-[10%] left-[60%] rotate-[45deg]"/> <Skull size={32} className="absolute top-[80%] left-[30%] rotate-[90deg]"/>
<Lego size={28} className="absolute top-[30%] left-[20%] rotate-[30deg]"/> <Trophy size={36} className="absolute fill-primary top-[10%] left-[60%] rotate-[45deg]"/>
<TreasureChest size={40} className="absolute top-[70%] left-[50%] rotate-[75deg]"/> <Lego size={28} className="absolute top-[30%] left-[20%] rotate-[30deg]"/>
</div> <TreasureChest size={40} className="absolute top-[70%] left-[50%] rotate-[75deg]"/>
{randomGamesFromLibrary.length > 0 &&
<div className="absolute flex flex-row">
{randomGamesFromLibrary.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.coverId}/>
))}
</div> </div>
} {randomGamesFromLibrary.length > 0 &&
<div className="absolute flex flex-row">
{randomGamesFromLibrary.map((game) => (
<GameCover game={game} size={100} radius="none" key={game.coverId}/>
))}
</div>
}
</div>
<p className="absolute text-2xl font-bold">{library.name}</p>
<div className="absolute right-0 top-0 flex flex-row">
<Tooltip content="Scan library" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={() => LibraryEndpoint.triggerScan([library])}>
<MagnifyingGlass/>
</Button>
</Tooltip>
<Tooltip content="Configuration" placement="bottom" color="foreground">
<Button isIconOnly variant="light" onPress={libraryDetailsModal.onOpen}>
<SlidersHorizontal/>
</Button>
</Tooltip>
</div>
</div> </div>
<p className="absolute text-2xl font-bold">{library.name}</p> {library.stats &&
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4">
<div className="absolute right-0 top-0 flex flex-row"> <p>Games</p>
<Tooltip content="Scan library" placement="bottom" color="foreground"> <p>Downloads</p>
<Button isIconOnly variant="light" onPress={() => LibraryEndpoint.triggerScan([library])}> <p>Platforms</p>
<MagnifyingGlass/> <p className="font-bold">{library.stats.gamesCount}</p>
</Button> <p className="font-bold">{library.stats.downloadedGamesCount}</p>
</Tooltip> <Chip size="sm">PC</Chip>
</div> </div>
</div> }
</Card>
{!!library.stats && <LibraryDetailsModal
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4"> library={library}
<p>Games</p> isOpen={libraryDetailsModal.isOpen}
<p>Downloads</p> onOpenChange={libraryDetailsModal.onOpenChange}
<p>Platforms</p> updateLibrary={updateLibrary}
<p className="font-bold">{library.stats.gamesCount}</p> />
<p className="font-bold">{library.stats.downloadedGamesCount}</p> </>
<Chip size="sm">PC</Chip>
</div>
}
</Card>
); );
} }
@@ -11,7 +11,7 @@ import {
useDisclosure useDisclosure
} from "@heroui/react"; } from "@heroui/react";
import {Form, Formik} from "formik"; import {Form, Formik} from "formik";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/LibraryDto"; import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
import {LibraryEndpoint} from "Frontend/generated/endpoints"; import {LibraryEndpoint} from "Frontend/generated/endpoints";
import Input from "Frontend/components/general/input/Input"; import Input from "Frontend/components/general/input/Input";
import PathPickerModal from "Frontend/components/general/modals/PathPickerModal"; import PathPickerModal from "Frontend/components/general/modals/PathPickerModal";
@@ -0,0 +1,58 @@
import React from "react";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
import {Form, Formik} from "formik";
import Input from "Frontend/components/general/input/Input";
import {LibraryEndpoint} from "Frontend/generated/endpoints";
import LibraryUpdateDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryUpdateDto";
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
interface LibraryDetailsModalProps {
library: LibraryDto;
isOpen: boolean;
onOpenChange: () => void;
updateLibrary: (library: LibraryUpdateDto) => void;
}
export default function LibraryDetailsModal({library, isOpen, onOpenChange, updateLibrary}: LibraryDetailsModalProps) {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<Formik initialValues={library}
enableReinitialize={true}
onSubmit={async (values: LibraryUpdateDto) => {
updateLibrary(values);
onClose();
}}
>
{(formik: { isSubmitting: any; }) => (
<Form>
<ModalHeader className="flex flex-col gap-1">
Edit library
</ModalHeader>
<ModalBody>
<Input key="name" name="name" label="Name"/>
<Button onPress={() => LibraryEndpoint.removeLibrary(library.id)}
color="danger">Delete</Button>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button
color="primary"
isLoading={formik.isSubmitting}
disabled={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : "Save"}
</Button>
</ModalFooter>
</Form>
)}
</Formik>
)}
</ModalContent>
</Modal>
);
}
@@ -55,7 +55,8 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange, update
{(formik: { isSubmitting: any; }) => ( {(formik: { isSubmitting: any; }) => (
<Form> <Form>
<ModalHeader className="flex flex-col gap-1"> <ModalHeader className="flex flex-col gap-1">
Plugin configuration for {plugin.name}</ModalHeader> Plugin configuration for {plugin.name}
</ModalHeader>
<ModalBody> <ModalBody>
<h4 className="text-l font-bold">Details</h4> <h4 className="text-l font-bold">Details</h4>
<div className="flex flex-row gap-8"> <div className="flex flex-row gap-8">
@@ -18,6 +18,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import me.xdrop.fuzzywuzzy.FuzzySearch import me.xdrop.fuzzywuzzy.FuzzySearch
import org.apache.commons.io.FilenameUtils
import org.pf4j.PluginManager import org.pf4j.PluginManager
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -44,8 +45,8 @@ class GameService(
return gameRepository.save(game) return gameRepository.save(game)
} }
fun createFromFile(path: Path): Game { fun createFromFile(path: Path): Game? {
val query = path.fileName.toString() val query = FilenameUtils.removeExtension(path.fileName.toString())
// Step 0: Query all metadata plugins for metadata on the provided game title // Step 0: Query all metadata plugins for metadata on the provided game title
val metadataResults = queryPlugins(query) val metadataResults = queryPlugins(query)
@@ -53,7 +54,8 @@ class GameService(
// Step 1: Filter out invalid (empty) results // Step 1: Filter out invalid (empty) results
val validResults = metadataResults.filterValuesNotNull() val validResults = metadataResults.filterValuesNotNull()
if (validResults.isEmpty()) { if (validResults.isEmpty()) {
throw NoMatchException("Could not match game at $path") log.error { "Could not identify game at path '$path'" }
return null
} }
// Step 2: Filter results to find the best matching title // Step 2: Filter results to find the best matching title
@@ -99,9 +101,9 @@ class GameService(
metadataPlugins.associateWith { metadataPlugins.associateWith {
async { async {
try { try {
it.fetchMetadata(gameTitle) it.fetchMetadata(gameTitle).firstOrNull()
} catch (e: Exception) { } catch (e: Exception) {
log.error(e) { "Error fetching metadata with plugin ${it.javaClass.name}" } log.error(e) { "Error fetching metadata for game with plugin ${it.javaClass.name}" }
null null
} }
}.await() }.await()
@@ -4,6 +4,8 @@ import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.games.GameService import de.grimsi.gameyfin.games.GameService
import de.grimsi.gameyfin.games.dto.GameDto import de.grimsi.gameyfin.games.dto.GameDto
import de.grimsi.gameyfin.libraries.dto.LibraryDto
import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
@@ -28,7 +30,17 @@ class LibraryEndpoint(
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun createLibrary(library: LibraryDto): LibraryDto { fun createLibrary(library: LibraryDto): LibraryDto {
return libraryService.createOrUpdate(library) return libraryService.create(library)
}
@RolesAllowed(Role.Names.ADMIN)
fun updateLibrary(library: LibraryUpdateDto): LibraryDto {
return libraryService.update(library)
}
@RolesAllowed(Role.Names.ADMIN)
fun removeLibrary(libraryId: Long) {
return libraryService.deleteLibrary(libraryId)
} }
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
@@ -4,6 +4,9 @@ import de.grimsi.gameyfin.core.filesystem.FilesystemService
import de.grimsi.gameyfin.games.GameService import de.grimsi.gameyfin.games.GameService
import de.grimsi.gameyfin.games.dto.GameDto import de.grimsi.gameyfin.games.dto.GameDto
import de.grimsi.gameyfin.games.entities.Game import de.grimsi.gameyfin.games.entities.Game
import de.grimsi.gameyfin.libraries.dto.LibraryDto
import de.grimsi.gameyfin.libraries.dto.LibraryStatsDto
import de.grimsi.gameyfin.libraries.dto.LibraryUpdateDto
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@@ -24,11 +27,30 @@ 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 createOrUpdate(library: LibraryDto): LibraryDto { fun create(library: LibraryDto): LibraryDto {
val entity = libraryRepository.save(toEntity(library)) val entity = libraryRepository.save(toEntity(library))
return toDto(entity) return toDto(entity)
} }
/**
* Updates a library entity with the non-null fields from a LibraryUpdateDto.
*
* @param libraryDto: The LibraryUpdateDto containing the fields to update.
* @return The updated LibraryDto.
* @throws IllegalArgumentException if the library ID is null or the library is not found.
*/
fun update(libraryDto: LibraryUpdateDto): LibraryDto {
val existingLibrary = libraryRepository.findByIdOrNull(libraryDto.id)
?: throw IllegalArgumentException("Library with ID $libraryDto.id not found")
// Update only non-null fields
libraryDto.name?.let { existingLibrary.name = it }
libraryDto.directories?.let { existingLibrary.directories = it.toMutableSet() }
val updatedLibrary = libraryRepository.save(existingLibrary)
return toDto(updatedLibrary)
}
/** /**
* Retrieves all libraries from the repository. * Retrieves all libraries from the repository.
*/ */
@@ -40,11 +62,10 @@ class LibraryService(
/** /**
* Deletes a library from the repository. * Deletes a library from the repository.
* *
* @param library: The library to delete. * @param libraryId: ID of the library to delete.
*/ */
fun deleteLibrary(library: LibraryDto) { fun deleteLibrary(libraryId: Long) {
val entity = toEntity(library) libraryRepository.deleteById(libraryId)
libraryRepository.delete(entity)
} }
/** /**
@@ -114,7 +135,10 @@ class LibraryService(
val libraries = libraryDtos?.map { toEntity(it) } ?: libraryRepository.findAll() val libraries = libraryDtos?.map { toEntity(it) } ?: libraryRepository.findAll()
libraries.forEach { library -> libraries.forEach { library ->
val gamePaths = filesystemService.scanLibraryForGamefiles(library) val gamePaths = filesystemService.scanLibraryForGamefiles(library)
val newGames = gamePaths.map { gameService.createFromFile(it) } val newGames = gamePaths.mapNotNull {
gameService.createFromFile(it)
}
addGamesToLibrary(newGames, library) addGamesToLibrary(newGames, library)
} }
} }
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.libraries package de.grimsi.gameyfin.libraries.dto
data class LibraryDto( data class LibraryDto(
val id: Long, val id: Long,
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.libraries package de.grimsi.gameyfin.libraries.dto
data class LibraryStatsDto( data class LibraryStatsDto(
val gamesCount: Int, val gamesCount: Int,
@@ -0,0 +1,7 @@
package de.grimsi.gameyfin.libraries.dto
data class LibraryUpdateDto(
val id: Long,
val name: String? = null,
val directories: Set<String>? = null,
)
@@ -1,2 +1,3 @@
logging.level.de.grimsi.gameyfin: DEBUG
logging.level.org.hibernate.SQL: DEBUG logging.level.org.hibernate.SQL: DEBUG
logging.level.org.hibernate.type: TRACE logging.level.org.hibernate.type: TRACE
+5 -8
View File
@@ -1,13 +1,10 @@
# Plugin versions # Plugin versions
kotlinVersion=2.1.20 kotlinVersion=2.1.20
kspVersion=2.1.20-1.0.32 kspVersion=2.1.20-2.0.1
vaadinVersion=24.7.1 vaadinVersion=24.7.3
springBootVersion=3.4.4 springBootVersion=3.4.5
springCloudVersion=2024.0.0 springCloudVersion=2024.0.1
springDependencyManagementVersion=1.1.7 springDependencyManagementVersion=1.1.7
# Dependency versions # Dependency versions
pf4jVersion=3.13.0 pf4jVersion=3.13.0
pf4jKspVersion=2.1.20-1.0.2 pf4jKspVersion=2.1.20-1.0.2
# Annotation processor settings
kapt.use.k2=true
ksp.useKSP2=true
@@ -3,5 +3,5 @@ package de.grimsi.gameyfin.pluginapi.gamemetadata
import org.pf4j.ExtensionPoint import org.pf4j.ExtensionPoint
interface GameMetadataProvider : ExtensionPoint { interface GameMetadataProvider : ExtensionPoint {
fun fetchMetadata(gameId: String): GameMetadata? fun fetchMetadata(gameId: String, maxResults: Int = 1): List<GameMetadata>
} }
@@ -12,6 +12,7 @@ import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import me.xdrop.fuzzywuzzy.FuzzySearch import me.xdrop.fuzzywuzzy.FuzzySearch
import org.pf4j.Extension import org.pf4j.Extension
import org.pf4j.PluginWrapper import org.pf4j.PluginWrapper
import proto.Game
import java.time.Instant import java.time.Instant
class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) { class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
@@ -93,32 +94,40 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
"platforms.platform_logo.image_id" "platforms.platform_logo.image_id"
).joinToString(",") ).joinToString(",")
override fun fetchMetadata(gameId: String): GameMetadata? { override fun fetchMetadata(gameId: String, maxResults: Int): List<GameMetadata> {
val findBySlugQuery = APICalypse() val findBySlugQuery = APICalypse()
.fields(QUERY_FIELDS) .fields(QUERY_FIELDS)
.where("slug = \"${guessSlug(gameId)}\"") .where("slug = \"${guessSlug(gameId)}\"")
// First step: Try to find the game by guessing the slug // First step: Try to find the game by guessing the slug
var game = IGDBWrapper.games(findBySlugQuery).firstOrNull() var games = IGDBWrapper.games(findBySlugQuery)
// Second step: Try a fuzzy search // Second step: Try a fuzzy search
if (game == null) { // Note: Limit is intentionally set high because IGDBs ranking algorithm is not very good
if (games.isEmpty()) {
val searchByNameQuery = APICalypse() val searchByNameQuery = APICalypse()
.fields(QUERY_FIELDS) .fields(QUERY_FIELDS)
.limit(100) .limit(100)
.search(gameId) .search(gameId)
// Use IGDBs search function to get a list of games that match the search query // Use IGDBs search function to get a list of games that match the search query
val games = IGDBWrapper.games(searchByNameQuery) games = IGDBWrapper.games(searchByNameQuery)
if (games.isEmpty()) return null if (games.isEmpty()) return emptyList()
// Use fuzzy search to find the best matching game name // Use fuzzy search to find the best matching game name
val bestMatchingName = FuzzySearch.extractOne(gameId, games.map { it.name }).string val bestMatchingTitles = FuzzySearch.extractTop(gameId, games.map { it.name }, maxResults)
games = bestMatchingTitles.mapNotNull { title -> games.find { it.name == title.string } }
game = games.find { it.name == bestMatchingName } ?: return null
} }
return games.map { toGameMetadata(it) }
}
private fun guessSlug(gameId: String): String {
return gameId.replace(" ", "-").lowercase()
}
private fun toGameMetadata(game: Game): GameMetadata {
return GameMetadata( return GameMetadata(
originalId = game.slug, originalId = game.slug,
title = game.name, title = game.name,
@@ -138,9 +147,5 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
perspectives = game.playerPerspectivesList.map { Mapper.playerPerspective(it) }.toSet() perspectives = game.playerPerspectivesList.map { Mapper.playerPerspective(it) }.toSet()
) )
} }
private fun guessSlug(gameId: String): String {
return gameId.replace(" ", "-").lowercase()
}
} }
} }
@@ -52,6 +52,7 @@ class Mapper {
"fantasy" -> Theme.FANTASY "fantasy" -> Theme.FANTASY
"horror" -> Theme.HORROR "horror" -> Theme.HORROR
"sci-fi" -> Theme.SCIENCE_FICTION "sci-fi" -> Theme.SCIENCE_FICTION
"science-fiction" -> Theme.SCIENCE_FICTION
"mystery" -> Theme.MYSTERY "mystery" -> Theme.MYSTERY
"thriller" -> Theme.THRILLER "thriller" -> Theme.THRILLER
"survival" -> Theme.SURVIVAL "survival" -> Theme.SURVIVAL
@@ -82,6 +83,7 @@ class Mapper {
"first-person" -> PlayerPerspective.FIRST_PERSON "first-person" -> PlayerPerspective.FIRST_PERSON
"third-person" -> PlayerPerspective.THIRD_PERSON "third-person" -> PlayerPerspective.THIRD_PERSON
"bird-view-isometric" -> PlayerPerspective.BIRD_VIEW_ISOMETRIC "bird-view-isometric" -> PlayerPerspective.BIRD_VIEW_ISOMETRIC
"bird-view-slash-isometric" -> PlayerPerspective.BIRD_VIEW_ISOMETRIC
"side-view" -> PlayerPerspective.SIDE_VIEW "side-view" -> PlayerPerspective.SIDE_VIEW
"text" -> PlayerPerspective.TEXT "text" -> PlayerPerspective.TEXT
"auditory" -> PlayerPerspective.AUDITORY "auditory" -> PlayerPerspective.AUDITORY
@@ -57,14 +57,14 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
* The Steam Store API I am using provides far less info than IGDB for example * The Steam Store API I am using provides far less info than IGDB for example
* See it more as a proof of concept than a fully functional plugin * See it more as a proof of concept than a fully functional plugin
**/ **/
override fun fetchMetadata(gameId: String): GameMetadata? { override fun fetchMetadata(gameId: String, maxResults: Int): List<GameMetadata> {
val searchResult: List<SteamGame> = runBlocking { searchStore(gameId) } val searchResult: List<SteamGame> = runBlocking { searchStore(gameId) }
if (searchResult.isEmpty()) return null if (searchResult.isEmpty()) return emptyList()
val bestMatchingTitle = FuzzySearch.extractOne(gameId, searchResult.map { it.name }).string val bestMatchingTitles = FuzzySearch.extractTop(gameId, searchResult.map { it.name }, maxResults)
val bestMatch = searchResult.find { it.name == bestMatchingTitle } ?: return null val bestMatches = bestMatchingTitles.mapNotNull { title -> searchResult.find { it.name == title.string } }
return runBlocking { getGameDetails(bestMatch.id) } return runBlocking { bestMatches.map { getGameDetails(it.id) } }.filterNotNull()
} }
private suspend fun searchStore(title: String): List<SteamGame> { private suspend fun searchStore(title: String): List<SteamGame> {
@@ -105,7 +105,7 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
// This is as much as I can get from the Steam Store API // This is as much as I can get from the Steam Store API
val metadata = GameMetadata( val metadata = GameMetadata(
originalId = id.toString(), originalId = id.toString(),
title = game.name, title = sanitizeTitle(game.name),
description = game.detailedDescription, description = game.detailedDescription,
coverUrl = game.headerImage?.let { URI(it) }, coverUrl = game.headerImage?.let { URI(it) },
release = game.releaseDate?.date, release = game.releaseDate?.date,
@@ -119,5 +119,15 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
return metadata return metadata
} }
/**
* Often titles on Steam copyright symbols which makes matching between different providers harder
* This method removes those symbols
*/
private fun sanitizeTitle(originalTitle: String): String {
val unwantedChars = setOf('™', '©', '®')
return originalTitle.filter { it !in unwantedChars }.trim()
}
} }
} }