mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
Update dependenicies
Update Plugin-API to allow multiple results per plugin Add LibraryDetailsModal Minor refactorings & bugfixes
This commit is contained in:
Generated
+1009
-1823
File diff suppressed because it is too large
Load Diff
+67
-67
@@ -9,22 +9,22 @@
|
||||
"@polymer/polymer": "3.5.2",
|
||||
"@react-stately/data": "^3.12.2",
|
||||
"@react-types/shared": "^3.28.0",
|
||||
"@vaadin/bundles": "24.7.1",
|
||||
"@vaadin/bundles": "24.7.5",
|
||||
"@vaadin/common-frontend": "0.0.19",
|
||||
"@vaadin/hilla-file-router": "24.7.1",
|
||||
"@vaadin/hilla-frontend": "24.7.1",
|
||||
"@vaadin/hilla-lit-form": "24.7.1",
|
||||
"@vaadin/hilla-react-auth": "24.7.1",
|
||||
"@vaadin/hilla-react-crud": "24.7.1",
|
||||
"@vaadin/hilla-react-form": "24.7.1",
|
||||
"@vaadin/hilla-react-i18n": "24.7.1",
|
||||
"@vaadin/hilla-react-signals": "24.7.1",
|
||||
"@vaadin/polymer-legacy-adapter": "24.7.1",
|
||||
"@vaadin/react-components": "24.7.1",
|
||||
"@vaadin/hilla-file-router": "24.7.3",
|
||||
"@vaadin/hilla-frontend": "24.7.3",
|
||||
"@vaadin/hilla-lit-form": "24.7.3",
|
||||
"@vaadin/hilla-react-auth": "24.7.3",
|
||||
"@vaadin/hilla-react-crud": "24.7.3",
|
||||
"@vaadin/hilla-react-form": "24.7.3",
|
||||
"@vaadin/hilla-react-i18n": "24.7.3",
|
||||
"@vaadin/hilla-react-signals": "24.7.3",
|
||||
"@vaadin/polymer-legacy-adapter": "24.7.5",
|
||||
"@vaadin/react-components": "24.7.5",
|
||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||
"@vaadin/vaadin-lumo-styles": "24.7.1",
|
||||
"@vaadin/vaadin-material-styles": "24.7.1",
|
||||
"@vaadin/vaadin-themable-mixin": "24.7.1",
|
||||
"@vaadin/vaadin-lumo-styles": "24.7.5",
|
||||
"@vaadin/vaadin-material-styles": "24.7.5",
|
||||
"@vaadin/vaadin-themable-mixin": "24.7.5",
|
||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||
"classnames": "^2.5.1",
|
||||
"construct-style-sheets-polyfill": "3.1.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
"formik": "^2.4.6",
|
||||
"framer-motion": "^12.5.0",
|
||||
"http-status-codes": "^2.3.0",
|
||||
"lit": "3.2.1",
|
||||
"lit": "3.3.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.47",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -43,7 +43,7 @@
|
||||
"react-aria-components": "^1.7.1",
|
||||
"react-confetti-boom": "^1.0.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router": "7.2.0",
|
||||
"react-router": "7.5.2",
|
||||
"yup": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -53,24 +53,24 @@
|
||||
"@rollup/plugin-replace": "6.0.2",
|
||||
"@rollup/pluginutils": "5.1.4",
|
||||
"@types/node": "^22.4.0",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@vaadin/hilla-generator-cli": "24.7.1",
|
||||
"@vaadin/hilla-generator-core": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-client": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-model": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-push": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-signals": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.7.1",
|
||||
"@vaadin/hilla-generator-utils": "24.7.1",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"@types/react": "18.3.20",
|
||||
"@types/react-dom": "18.3.6",
|
||||
"@vaadin/hilla-generator-cli": "24.7.3",
|
||||
"@vaadin/hilla-generator-core": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-client": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-model": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-push": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-signals": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.7.3",
|
||||
"@vaadin/hilla-generator-utils": "24.7.3",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||
"async": "3.2.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"glob": "11.0.1",
|
||||
"glob": "11.0.2",
|
||||
"postcss": "^8.4.41",
|
||||
"postcss-import": "^16.1.0",
|
||||
"rollup-plugin-brotli": "3.1.0",
|
||||
@@ -79,8 +79,8 @@
|
||||
"tailwindcss": "^3.4.13",
|
||||
"transform-ast": "2.4.4",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6.2.3",
|
||||
"vite-plugin-checker": "0.8.0",
|
||||
"vite": "6.3.3",
|
||||
"vite-plugin-checker": "0.9.1",
|
||||
"workbox-build": "7.3.0",
|
||||
"workbox-core": "7.3.0",
|
||||
"workbox-precaching": "7.3.0"
|
||||
@@ -133,62 +133,62 @@
|
||||
"vaadin": {
|
||||
"dependencies": {
|
||||
"@polymer/polymer": "3.5.2",
|
||||
"@vaadin/bundles": "24.7.1",
|
||||
"@vaadin/bundles": "24.7.5",
|
||||
"@vaadin/common-frontend": "0.0.19",
|
||||
"@vaadin/hilla-file-router": "24.7.1",
|
||||
"@vaadin/hilla-frontend": "24.7.1",
|
||||
"@vaadin/hilla-lit-form": "24.7.1",
|
||||
"@vaadin/hilla-react-auth": "24.7.1",
|
||||
"@vaadin/hilla-react-crud": "24.7.1",
|
||||
"@vaadin/hilla-react-form": "24.7.1",
|
||||
"@vaadin/hilla-react-i18n": "24.7.1",
|
||||
"@vaadin/hilla-react-signals": "24.7.1",
|
||||
"@vaadin/polymer-legacy-adapter": "24.7.1",
|
||||
"@vaadin/react-components": "24.7.1",
|
||||
"@vaadin/hilla-file-router": "24.7.3",
|
||||
"@vaadin/hilla-frontend": "24.7.3",
|
||||
"@vaadin/hilla-lit-form": "24.7.3",
|
||||
"@vaadin/hilla-react-auth": "24.7.3",
|
||||
"@vaadin/hilla-react-crud": "24.7.3",
|
||||
"@vaadin/hilla-react-form": "24.7.3",
|
||||
"@vaadin/hilla-react-i18n": "24.7.3",
|
||||
"@vaadin/hilla-react-signals": "24.7.3",
|
||||
"@vaadin/polymer-legacy-adapter": "24.7.5",
|
||||
"@vaadin/react-components": "24.7.5",
|
||||
"@vaadin/vaadin-development-mode-detector": "2.0.7",
|
||||
"@vaadin/vaadin-lumo-styles": "24.7.1",
|
||||
"@vaadin/vaadin-material-styles": "24.7.1",
|
||||
"@vaadin/vaadin-themable-mixin": "24.7.1",
|
||||
"@vaadin/vaadin-lumo-styles": "24.7.5",
|
||||
"@vaadin/vaadin-material-styles": "24.7.5",
|
||||
"@vaadin/vaadin-themable-mixin": "24.7.5",
|
||||
"@vaadin/vaadin-usage-statistics": "2.1.3",
|
||||
"construct-style-sheets-polyfill": "3.1.0",
|
||||
"date-fns": "2.29.3",
|
||||
"lit": "3.2.1",
|
||||
"lit": "3.3.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router": "7.2.0"
|
||||
"react-router": "7.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"@preact/signals-react-transform": "0.5.1",
|
||||
"@rollup/plugin-replace": "6.0.2",
|
||||
"@rollup/pluginutils": "5.1.4",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@vaadin/hilla-generator-cli": "24.7.1",
|
||||
"@vaadin/hilla-generator-core": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-client": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-model": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-push": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-signals": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "24.7.1",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.7.1",
|
||||
"@vaadin/hilla-generator-utils": "24.7.1",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"@types/react": "18.3.20",
|
||||
"@types/react-dom": "18.3.6",
|
||||
"@vaadin/hilla-generator-cli": "24.7.3",
|
||||
"@vaadin/hilla-generator-core": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-backbone": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-barrel": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-client": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-model": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-push": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-signals": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-subtypes": "24.7.3",
|
||||
"@vaadin/hilla-generator-plugin-transfertypes": "24.7.3",
|
||||
"@vaadin/hilla-generator-utils": "24.7.3",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"async": "3.2.6",
|
||||
"glob": "11.0.1",
|
||||
"glob": "11.0.2",
|
||||
"rollup-plugin-brotli": "3.1.0",
|
||||
"rollup-plugin-visualizer": "5.14.0",
|
||||
"strip-css-comments": "5.0.0",
|
||||
"transform-ast": "2.4.4",
|
||||
"typescript": "5.7.3",
|
||||
"vite": "6.2.3",
|
||||
"vite-plugin-checker": "0.8.0",
|
||||
"vite": "6.3.3",
|
||||
"vite-plugin-checker": "0.9.1",
|
||||
"workbox-build": "7.3.0",
|
||||
"workbox-core": "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 {Plus} from "@phosphor-icons/react";
|
||||
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 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) {
|
||||
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 (
|
||||
<div className="flex flex-col">
|
||||
<Section title="Permissions"/>
|
||||
@@ -54,7 +70,9 @@ function LibraryManagementLayout({getConfig, formik}: any) {
|
||||
{libraries.length > 0 ?
|
||||
// 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))]">
|
||||
{libraries.map((library) => <LibraryOverviewCard library={library} key={library.name}/>)}
|
||||
{libraries.map((library) =>
|
||||
<LibraryOverviewCard library={library} updateLibrary={updateLibrary} key={library.name}/>
|
||||
)}
|
||||
</div> :
|
||||
"No libraries configured. Add your first library!"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Button, Card, Chip, Tooltip} from "@heroui/react";
|
||||
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/LibraryDto";
|
||||
import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
|
||||
import LibraryDto from "Frontend/generated/de/grimsi/gameyfin/libraries/dto/LibraryDto";
|
||||
import GameDto from "Frontend/generated/de/grimsi/gameyfin/games/dto/GameDto";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {LibraryEndpoint} from "Frontend/generated/endpoints";
|
||||
@@ -14,18 +14,25 @@ import {
|
||||
Lego,
|
||||
MagnifyingGlass,
|
||||
Skull,
|
||||
SlidersHorizontal,
|
||||
SoccerBall,
|
||||
Strategy,
|
||||
Sword,
|
||||
TreasureChest,
|
||||
Trophy
|
||||
} 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 rand = new Rand(library.id.toString());
|
||||
|
||||
const [randomGamesFromLibrary, setRandomGamesFromLibrary] = useState<GameDto[]>([]);
|
||||
const libraryDetailsModal = useDisclosure();
|
||||
|
||||
useEffect(() => {
|
||||
LibraryEndpoint.getGamesInLibrary(library.id).then(
|
||||
@@ -35,6 +42,7 @@ export function LibraryOverviewCard({library}: { library: LibraryDto }) {
|
||||
|
||||
let gamesFromLibrary: GameDto[] = response
|
||||
.filter(g => !!g)
|
||||
.sort((a: GameDto, b: GameDto) => a.id - b.id)
|
||||
.sort(() => rand.next() - 0.5)
|
||||
.slice(0, count)
|
||||
|
||||
@@ -44,55 +52,68 @@ export function LibraryOverviewCard({library}: { library: LibraryDto }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col justify-between w-[353px]">
|
||||
<div className="flex flex-1 justify-center items-center">
|
||||
<div className="flex flex-1 opacity-10 min-h-[100px]">
|
||||
<div className="absolute w-full h-full opacity-50">
|
||||
<GameController size={32}
|
||||
className="absolute fill-primary top-[10%] left-[10%] rotate-[350deg]"/>
|
||||
<SoccerBall size={34}
|
||||
className="absolute fill-primary top-[50%] left-[35%] rotate-[60deg]"/>
|
||||
<Joystick size={40} className="absolute top-[30%] left-[50%] rotate-[90deg]"/>
|
||||
<Strategy size={36} className="absolute fill-primary top-[50%] left-[70%] rotate-[30deg]"/>
|
||||
<Sword size={28} className="absolute top-[70%] left-[10%] rotate-[60deg]"/>
|
||||
<Alien size={34} className="absolute fill-primary top-[10%] left-[85%] rotate-[15deg]"/>
|
||||
<CastleTurret size={30} className="absolute top-[5%] left-[40%] rotate-[320deg]"/>
|
||||
<Ghost size={38} className="absolute fill-primary top-[40%] left-[5%] rotate-[300deg]"/>
|
||||
<Skull size={32} className="absolute top-[80%] left-[30%] rotate-[90deg]"/>
|
||||
<Trophy size={36} className="absolute fill-primary top-[10%] left-[60%] rotate-[45deg]"/>
|
||||
<Lego size={28} className="absolute top-[30%] left-[20%] rotate-[30deg]"/>
|
||||
<TreasureChest size={40} className="absolute top-[70%] left-[50%] rotate-[75deg]"/>
|
||||
</div>
|
||||
{randomGamesFromLibrary.length > 0 &&
|
||||
<div className="absolute flex flex-row">
|
||||
{randomGamesFromLibrary.map((game) => (
|
||||
<GameCover game={game} size={100} radius="none" key={game.coverId}/>
|
||||
))}
|
||||
<>
|
||||
<Card className="flex flex-col justify-between w-[353px]">
|
||||
<div className="flex flex-1 justify-center items-center">
|
||||
<div className="flex flex-1 opacity-10 min-h-[100px]">
|
||||
<div className="absolute w-full h-full opacity-50">
|
||||
<GameController size={32}
|
||||
className="absolute fill-primary top-[10%] left-[10%] rotate-[350deg]"/>
|
||||
<SoccerBall size={34}
|
||||
className="absolute fill-primary top-[50%] left-[35%] rotate-[60deg]"/>
|
||||
<Joystick size={40} className="absolute top-[30%] left-[50%] rotate-[90deg]"/>
|
||||
<Strategy size={36} className="absolute fill-primary top-[50%] left-[70%] rotate-[30deg]"/>
|
||||
<Sword size={28} className="absolute top-[70%] left-[10%] rotate-[60deg]"/>
|
||||
<Alien size={34} className="absolute fill-primary top-[10%] left-[85%] rotate-[15deg]"/>
|
||||
<CastleTurret size={30} className="absolute top-[5%] left-[40%] rotate-[320deg]"/>
|
||||
<Ghost size={38} className="absolute fill-primary top-[40%] left-[5%] rotate-[300deg]"/>
|
||||
<Skull size={32} className="absolute top-[80%] left-[30%] rotate-[90deg]"/>
|
||||
<Trophy size={36} className="absolute fill-primary top-[10%] left-[60%] rotate-[45deg]"/>
|
||||
<Lego size={28} className="absolute top-[30%] left-[20%] rotate-[30deg]"/>
|
||||
<TreasureChest size={40} className="absolute top-[70%] left-[50%] rotate-[75deg]"/>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!!library.stats &&
|
||||
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4">
|
||||
<p>Games</p>
|
||||
<p>Downloads</p>
|
||||
<p>Platforms</p>
|
||||
<p className="font-bold">{library.stats.gamesCount}</p>
|
||||
<p className="font-bold">{library.stats.downloadedGamesCount}</p>
|
||||
<Chip size="sm">PC</Chip>
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
{library.stats &&
|
||||
<div className="grid grid-rows-2 grid-cols-3 justify-items-center items-center p-2 pt-4">
|
||||
<p>Games</p>
|
||||
<p>Downloads</p>
|
||||
<p>Platforms</p>
|
||||
<p className="font-bold">{library.stats.gamesCount}</p>
|
||||
<p className="font-bold">{library.stats.downloadedGamesCount}</p>
|
||||
<Chip size="sm">PC</Chip>
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
<LibraryDetailsModal
|
||||
library={library}
|
||||
isOpen={libraryDetailsModal.isOpen}
|
||||
onOpenChange={libraryDetailsModal.onOpenChange}
|
||||
updateLibrary={updateLibrary}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
useDisclosure
|
||||
} from "@heroui/react";
|
||||
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 Input from "Frontend/components/general/input/Input";
|
||||
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; }) => (
|
||||
<Form>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
Plugin configuration for {plugin.name}</ModalHeader>
|
||||
Plugin configuration for {plugin.name}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<h4 className="text-l font-bold">Details</h4>
|
||||
<div className="flex flex-row gap-8">
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.xdrop.fuzzywuzzy.FuzzySearch
|
||||
import org.apache.commons.io.FilenameUtils
|
||||
import org.pf4j.PluginManager
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -44,8 +45,8 @@ class GameService(
|
||||
return gameRepository.save(game)
|
||||
}
|
||||
|
||||
fun createFromFile(path: Path): Game {
|
||||
val query = path.fileName.toString()
|
||||
fun createFromFile(path: Path): Game? {
|
||||
val query = FilenameUtils.removeExtension(path.fileName.toString())
|
||||
|
||||
// Step 0: Query all metadata plugins for metadata on the provided game title
|
||||
val metadataResults = queryPlugins(query)
|
||||
@@ -53,7 +54,8 @@ class GameService(
|
||||
// Step 1: Filter out invalid (empty) results
|
||||
val validResults = metadataResults.filterValuesNotNull()
|
||||
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
|
||||
@@ -99,9 +101,9 @@ class GameService(
|
||||
metadataPlugins.associateWith {
|
||||
async {
|
||||
try {
|
||||
it.fetchMetadata(gameTitle)
|
||||
it.fetchMetadata(gameTitle).firstOrNull()
|
||||
} 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
|
||||
}
|
||||
}.await()
|
||||
|
||||
@@ -4,6 +4,8 @@ import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.games.GameService
|
||||
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.RolesAllowed
|
||||
|
||||
@@ -28,7 +30,17 @@ class LibraryEndpoint(
|
||||
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,9 @@ import de.grimsi.gameyfin.core.filesystem.FilesystemService
|
||||
import de.grimsi.gameyfin.games.GameService
|
||||
import de.grimsi.gameyfin.games.dto.GameDto
|
||||
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 kotlinx.coroutines.runBlocking
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
@@ -24,11 +27,30 @@ class LibraryService(
|
||||
* @param library: The library to create or update.
|
||||
* @return The created or updated LibraryDto object.
|
||||
*/
|
||||
fun createOrUpdate(library: LibraryDto): LibraryDto {
|
||||
fun create(library: LibraryDto): LibraryDto {
|
||||
val entity = libraryRepository.save(toEntity(library))
|
||||
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.
|
||||
*/
|
||||
@@ -40,11 +62,10 @@ class LibraryService(
|
||||
/**
|
||||
* Deletes a library from the repository.
|
||||
*
|
||||
* @param library: The library to delete.
|
||||
* @param libraryId: ID of the library to delete.
|
||||
*/
|
||||
fun deleteLibrary(library: LibraryDto) {
|
||||
val entity = toEntity(library)
|
||||
libraryRepository.delete(entity)
|
||||
fun deleteLibrary(libraryId: Long) {
|
||||
libraryRepository.deleteById(libraryId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,7 +135,10 @@ class LibraryService(
|
||||
val libraries = libraryDtos?.map { toEntity(it) } ?: libraryRepository.findAll()
|
||||
libraries.forEach { library ->
|
||||
val gamePaths = filesystemService.scanLibraryForGamefiles(library)
|
||||
val newGames = gamePaths.map { gameService.createFromFile(it) }
|
||||
val newGames = gamePaths.mapNotNull {
|
||||
gameService.createFromFile(it)
|
||||
}
|
||||
|
||||
addGamesToLibrary(newGames, library)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package de.grimsi.gameyfin.libraries
|
||||
package de.grimsi.gameyfin.libraries.dto
|
||||
|
||||
data class LibraryDto(
|
||||
val id: Long,
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package de.grimsi.gameyfin.libraries
|
||||
package de.grimsi.gameyfin.libraries.dto
|
||||
|
||||
data class LibraryStatsDto(
|
||||
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.type: TRACE
|
||||
+5
-8
@@ -1,13 +1,10 @@
|
||||
# Plugin versions
|
||||
kotlinVersion=2.1.20
|
||||
kspVersion=2.1.20-1.0.32
|
||||
vaadinVersion=24.7.1
|
||||
springBootVersion=3.4.4
|
||||
springCloudVersion=2024.0.0
|
||||
kspVersion=2.1.20-2.0.1
|
||||
vaadinVersion=24.7.3
|
||||
springBootVersion=3.4.5
|
||||
springCloudVersion=2024.0.1
|
||||
springDependencyManagementVersion=1.1.7
|
||||
# Dependency versions
|
||||
pf4jVersion=3.13.0
|
||||
pf4jKspVersion=2.1.20-1.0.2
|
||||
# Annotation processor settings
|
||||
kapt.use.k2=true
|
||||
ksp.useKSP2=true
|
||||
pf4jKspVersion=2.1.20-1.0.2
|
||||
+1
-1
@@ -3,5 +3,5 @@ package de.grimsi.gameyfin.pluginapi.gamemetadata
|
||||
import org.pf4j.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 org.pf4j.Extension
|
||||
import org.pf4j.PluginWrapper
|
||||
import proto.Game
|
||||
import java.time.Instant
|
||||
|
||||
class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
||||
@@ -93,32 +94,40 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
||||
"platforms.platform_logo.image_id"
|
||||
).joinToString(",")
|
||||
|
||||
override fun fetchMetadata(gameId: String): GameMetadata? {
|
||||
override fun fetchMetadata(gameId: String, maxResults: Int): List<GameMetadata> {
|
||||
val findBySlugQuery = APICalypse()
|
||||
.fields(QUERY_FIELDS)
|
||||
.where("slug = \"${guessSlug(gameId)}\"")
|
||||
|
||||
// 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
|
||||
if (game == null) {
|
||||
// Note: Limit is intentionally set high because IGDBs ranking algorithm is not very good
|
||||
if (games.isEmpty()) {
|
||||
val searchByNameQuery = APICalypse()
|
||||
.fields(QUERY_FIELDS)
|
||||
.limit(100)
|
||||
.search(gameId)
|
||||
|
||||
// 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
|
||||
val bestMatchingName = FuzzySearch.extractOne(gameId, games.map { it.name }).string
|
||||
|
||||
game = games.find { it.name == bestMatchingName } ?: return null
|
||||
val bestMatchingTitles = FuzzySearch.extractTop(gameId, games.map { it.name }, maxResults)
|
||||
games = bestMatchingTitles.mapNotNull { title -> games.find { it.name == title.string } }
|
||||
}
|
||||
|
||||
return games.map { toGameMetadata(it) }
|
||||
}
|
||||
|
||||
private fun guessSlug(gameId: String): String {
|
||||
return gameId.replace(" ", "-").lowercase()
|
||||
}
|
||||
|
||||
private fun toGameMetadata(game: Game): GameMetadata {
|
||||
return GameMetadata(
|
||||
originalId = game.slug,
|
||||
title = game.name,
|
||||
@@ -138,9 +147,5 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
||||
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
|
||||
"horror" -> Theme.HORROR
|
||||
"sci-fi" -> Theme.SCIENCE_FICTION
|
||||
"science-fiction" -> Theme.SCIENCE_FICTION
|
||||
"mystery" -> Theme.MYSTERY
|
||||
"thriller" -> Theme.THRILLER
|
||||
"survival" -> Theme.SURVIVAL
|
||||
@@ -82,6 +83,7 @@ class Mapper {
|
||||
"first-person" -> PlayerPerspective.FIRST_PERSON
|
||||
"third-person" -> PlayerPerspective.THIRD_PERSON
|
||||
"bird-view-isometric" -> PlayerPerspective.BIRD_VIEW_ISOMETRIC
|
||||
"bird-view-slash-isometric" -> PlayerPerspective.BIRD_VIEW_ISOMETRIC
|
||||
"side-view" -> PlayerPerspective.SIDE_VIEW
|
||||
"text" -> PlayerPerspective.TEXT
|
||||
"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
|
||||
* 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) }
|
||||
if (searchResult.isEmpty()) return null
|
||||
if (searchResult.isEmpty()) return emptyList()
|
||||
|
||||
val bestMatchingTitle = FuzzySearch.extractOne(gameId, searchResult.map { it.name }).string
|
||||
val bestMatch = searchResult.find { it.name == bestMatchingTitle } ?: return null
|
||||
val bestMatchingTitles = FuzzySearch.extractTop(gameId, searchResult.map { it.name }, maxResults)
|
||||
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> {
|
||||
@@ -105,7 +105,7 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
||||
// This is as much as I can get from the Steam Store API
|
||||
val metadata = GameMetadata(
|
||||
originalId = id.toString(),
|
||||
title = game.name,
|
||||
title = sanitizeTitle(game.name),
|
||||
description = game.detailedDescription,
|
||||
coverUrl = game.headerImage?.let { URI(it) },
|
||||
release = game.releaseDate?.date,
|
||||
@@ -119,5 +119,15 @@ class SteamPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user