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",
"@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,4 +1,4 @@
package de.grimsi.gameyfin.libraries
package de.grimsi.gameyfin.libraries.dto
data class LibraryDto(
val id: Long,
@@ -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
View File
@@ -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
@@ -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()
}
}
}