Finish plugin support implementation

Small refactoring
This commit is contained in:
grimsi
2024-10-31 14:33:35 +01:00
parent 5ebd074d3b
commit 34f00c091f
21 changed files with 305 additions and 163 deletions
@@ -1,69 +1,29 @@
import React, {useEffect, useState} from "react";
import Section from "Frontend/components/general/Section";
import {PluginConfigEndpoint} from "Frontend/generated/endpoints";
import {Form, Formik} from "formik";
import {Check} from "@phosphor-icons/react";
import {Button} from "@nextui-org/react";
import Input from "Frontend/components/general/Input";
import {PluginManagementEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto";
import {PluginManagementCard} from "Frontend/components/general/PluginManagementCard";
import {Divider} from "@nextui-org/react";
export default function PluginManagement() {
const [configSaved, setConfigSaved] = useState(false);
const [igdbConfigMeta, setIgdbConfigMeta] = useState<any>();
const [igdbConfig, setIgdbConfig] = useState<any>();
const [plugins, setPlugins] = useState<PluginDto[]>([]);
useEffect(() => {
PluginConfigEndpoint.getConfigMetadata("igdb").then(setIgdbConfigMeta);
PluginConfigEndpoint.getConfig("igdb").then(setIgdbConfig);
PluginManagementEndpoint.getPlugins().then((response) => {
if (response === undefined) return;
setPlugins(response as PluginDto[]);
});
}, []);
useEffect(() => {
if (configSaved) {
setTimeout(() => setConfigSaved(false), 2000);
}
}, [configSaved])
async function handleSubmit(values: any) {
await PluginConfigEndpoint.setConfigEntries("igdb", values);
setConfigSaved(true);
}
return (
<>
<Formik
initialValues={{
clientId: igdbConfig?.clientId,
clientSecret: igdbConfig?.clientSecret
}}
enableReinitialize={true}
onSubmit={handleSubmit}
>
{(formik: { values: any; isSubmitting: any; }) => (
<Form>
<div className="flex flex-row flex-grow justify-between mb-8">
<h2 className="text-2xl font-bold">Plugins</h2>
<div className="flex flex-row items-center gap-4">
<Button
color="primary"
isLoading={formik.isSubmitting}
disabled={formik.isSubmitting || configSaved}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
</Button>
</div>
</div>
<div className="flex flex-col">
<div className="flex flex-row flex-grow justify-between mb-8">
<h2 className="text-2xl font-bold">Plugins</h2>
</div>
<div className="flex flex-row flex-1 justify-between gap-16">
<div className="flex flex-col flex-grow">
<Section title="IGDB"/>
{igdbConfigMeta && igdbConfigMeta.map((entry: any) => (
<Input key={entry.key} name={entry.key} label={entry.name} type="text"/>
))}
</div>
</div>
</Form>
)}
</Formik>
</>
<Divider className="mb-4"/>
<div className="grid grid-cols-300px gap-4">
{plugins.map((plugin) => <PluginManagementCard plugin={plugin} key={plugin.name}/>)}
</div>
</div>
);
}
@@ -26,11 +26,7 @@ interface Role {
id: string;
}
export default function AssignRolesModal({
isOpen,
onOpenChange,
user
}: AssignRolesModalProps) {
export default function AssignRolesModal({isOpen, onOpenChange, user}: AssignRolesModalProps) {
const [availableRoles, setAvailableRoles] = useState<Role[]>([]);
const [selectedRole, setSelectedRole] = useState<Selection>();
const [error, setError] = useState<string>();
@@ -9,11 +9,7 @@ interface ConfirmUserDeletionModalProps {
user: UserInfoDto;
}
export default function ConfirmUserDeletionModal({
isOpen,
onOpenChange,
user
}: ConfirmUserDeletionModalProps) {
export default function ConfirmUserDeletionModal({isOpen, onOpenChange, user}: ConfirmUserDeletionModalProps) {
const [confirmUsername, setConfirmUsername] = useState<string>("");
useEffect(() => {
@@ -8,10 +8,7 @@ interface InviteUserModalProps {
onOpenChange: () => void;
}
export default function InviteUserModal({
isOpen,
onOpenChange
}: InviteUserModalProps) {
export default function InviteUserModal({isOpen, onOpenChange}: InviteUserModalProps) {
const [email, setEmail] = useState<string | null>();
const [error, setError] = useState<string | null>();
@@ -9,11 +9,7 @@ interface PasswordResetTokenModalProps {
token: TokenDto;
}
export default function PasswordResetTokenModal({
isOpen,
onOpenChange,
token
}: PasswordResetTokenModalProps) {
export default function PasswordResetTokenModal({isOpen, onOpenChange, token}: PasswordResetTokenModalProps) {
const [timeUntilExpiry, setTimeUntilExpiry] = useState<string>("");
const timeoutRefresh = setInterval(updateTimeUntilExpiry, 1000);
@@ -0,0 +1,75 @@
import React, {useEffect, useState} from "react";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
import {toast} from "sonner";
import {Form, Formik} from "formik";
import {PluginConfigEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto";
import PluginConfigElement from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigElement";
import Input from "Frontend/components/general/Input";
interface PluginConfigurationModalProps {
plugin: PluginDto;
isOpen: boolean;
onOpenChange: () => void;
}
export default function PluginConfigurationModal({plugin, isOpen, onOpenChange}: PluginConfigurationModalProps) {
const [pluginConfigMeta, setPluginConfigMeta] = useState<(PluginConfigElement)[]>();
const [pluginConfig, setPluginConfig] = useState<Record<string, string>>();
useEffect(() => {
PluginConfigEndpoint.getConfigMetadata(plugin.id).then(response => {
if (response === undefined) return;
setPluginConfigMeta(response as PluginConfigElement[]);
});
PluginConfigEndpoint.getConfig(plugin.id).then(response => {
if (response === undefined) return;
setPluginConfig(response as Record<string, string>);
});
}, []);
async function saveConfig(values: Record<string, string>) {
await PluginConfigEndpoint.setConfigEntries(plugin.id, values);
toast.success(`Configuration for ${plugin.name} saved!`);
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" size="lg">
<ModalContent>
{(onClose) => (
<Formik initialValues={pluginConfig}
enableReinitialize={true}
onSubmit={async (values: any) => {
await saveConfig(values);
onClose();
}}
>
{(formik: { isSubmitting: any; }) => (
<Form>
<ModalHeader className="flex flex-col gap-1">{plugin.name} configuration</ModalHeader>
<ModalBody>
{pluginConfigMeta && pluginConfigMeta.map((entry: any) => (
<Input key={entry.key} name={entry.key} label={entry.name} type="text"/>
))}
</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>
);
}
@@ -0,0 +1,50 @@
import {Card, Chip, Tooltip, useDisclosure} from "@nextui-org/react";
import {PuzzlePiece} from "@phosphor-icons/react";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginDto";
import PluginState from "Frontend/generated/org/pf4j/PluginState";
import React from "react";
import PluginConfigurationModal from "Frontend/components/general/PluginConfigurationModal";
export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
const pluginConfigurationModal = useDisclosure();
function stateToColor(state: PluginState | undefined): string {
switch (state) {
case PluginState.STARTED:
return "success";
case PluginState.DISABLED:
return "warning";
case PluginState.STOPPED:
return "danger";
default:
return "";
}
}
return (
<>
<Card className="flex flex-row justify-between p-2"
isPressable={true} onPress={pluginConfigurationModal.onOpen}>
<div className="flex flex-row items-center gap-4">
<Tooltip placement="right" content={`Plugin ${plugin.state!.toLowerCase()}`}>
<PuzzlePiece size={64} weight="duotone" className={`text-${stateToColor(plugin.state)}`}/>
</Tooltip>
<div className="flex flex-col items-start gap-1">
<div className="flex flex-row gap-2">
<p className="font-semibold">{plugin.name}</p>
<div className="text-sm">
<Chip size="sm" radius="sm" className="text-xs">{plugin.version}</Chip>
</div>
</div>
<p className="text-sm">Author: {plugin.author}</p>
</div>
</div>
</Card>
<PluginConfigurationModal plugin={plugin}
isOpen={pluginConfigurationModal.isOpen}
onOpenChange={pluginConfigurationModal.onOpenChange}
/>
</>
)
}
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.core.plugins
package de.grimsi.gameyfin.core.plugins.config
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.core.plugins
package de.grimsi.gameyfin.core.plugins.config
import de.grimsi.gameyfin.core.security.EncryptionConverter
import jakarta.persistence.Column
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.core.plugins
package de.grimsi.gameyfin.core.plugins.config
import org.springframework.data.jpa.repository.JpaRepository
@@ -1,7 +1,9 @@
package de.grimsi.gameyfin.core.plugins
package de.grimsi.gameyfin.core.plugins.config
import de.grimsi.gameyfin.core.plugins.management.GameyfinPluginManager
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service
@Service
@@ -10,22 +12,28 @@ class PluginConfigService(
private val pluginManager: GameyfinPluginManager
) {
private val log = KotlinLogging.logger {}
fun getConfigMetadata(pluginId: String): List<PluginConfigElement> {
log.info { "Getting config metadata for plugin $pluginId" }
val plugin = pluginManager.getPlugin(pluginId).plugin as GameyfinPlugin
return plugin.configMetadata
}
fun getConfig(pluginId: String): Map<String, String?> {
log.info { "Getting config for plugin $pluginId" }
return pluginConfigRepository.findAllById_PluginId(pluginId).associate { it.id.key to it.value }
}
fun setConfigEntries(pluginId: String, config: Map<String, String>) {
log.info { "Setting config entries for plugin $pluginId" }
val entries = config.map { PluginConfigEntry(PluginConfigEntryKey(pluginId, it.key), it.value) }
pluginConfigRepository.saveAll(entries)
pluginManager.restart(pluginId)
}
fun setConfigEntry(pluginId: String, key: String, value: String) {
log.info { "Setting config entry $key for plugin $pluginId" }
val entry = PluginConfigEntry(PluginConfigEntryKey(pluginId, key), value)
pluginConfigRepository.save(entry)
pluginManager.restart(pluginId)
@@ -1,4 +1,4 @@
package de.grimsi.gameyfin.core.plugins
package de.grimsi.gameyfin.core.plugins.management
import org.pf4j.DevelopmentPluginLoader
import org.pf4j.PluginClassLoader
@@ -1,5 +1,6 @@
package de.grimsi.gameyfin.core.plugins
package de.grimsi.gameyfin.core.plugins.management
import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
import io.github.oshai.kotlinlogging.KotlinLogging
import org.pf4j.CompoundPluginLoader
@@ -0,0 +1,11 @@
package de.grimsi.gameyfin.core.plugins.management
import org.pf4j.PluginState
data class PluginDto(
val id: String,
val name: String,
val version: String,
val author: String,
val state: PluginState
)
@@ -0,0 +1,19 @@
package de.grimsi.gameyfin.core.plugins.management
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.RolesAllowed
@Endpoint
@RolesAllowed(Role.Names.ADMIN)
class PluginManagementEndpoint(
private val pluginManagementService: PluginManagementService
) {
fun getPlugins() = pluginManagementService.getPlugins()
fun startPlugin(pluginId: String) = pluginManagementService.startPlugin(pluginId)
fun stopPlugin(pluginId: String) = pluginManagementService.stopPlugin(pluginId)
fun restartPlugin(pluginId: String) = pluginManagementService.restartPlugin(pluginId)
}
@@ -0,0 +1,32 @@
package de.grimsi.gameyfin.core.plugins.management
import org.springframework.stereotype.Service
@Service
class PluginManagementService(
private val pluginManager: GameyfinPluginManager
) {
fun getPlugins(): List<PluginDto> {
return pluginManager.plugins.map {
PluginDto(
it.pluginId,
it.descriptor.pluginDescription,
it.descriptor.version,
it.descriptor.provider,
it.pluginState
)
}
}
fun startPlugin(pluginId: String) {
pluginManager.startPlugin(pluginId)
}
fun stopPlugin(pluginId: String) {
pluginManager.stopPlugin(pluginId)
}
fun restartPlugin(pluginId: String) {
pluginManager.restart(pluginId)
}
}
@@ -1,5 +1,6 @@
package de.grimsi.gameyfin.core.plugins
package de.grimsi.gameyfin.core.plugins.management
import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.annotation.Bean
@@ -9,11 +9,8 @@ import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import de.grimsi.gameyfin.pluginapi.core.PluginConfigError
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataFetcher
import de.grimsi.gameyfin.pluginapi.gamemetadata.Genre
import de.grimsi.gameyfin.pluginapi.gamemetadata.Theme
import org.pf4j.Extension
import org.pf4j.PluginWrapper
import org.slf4j.LoggerFactory
import java.time.Instant
import kotlin.collections.filter
@@ -52,8 +49,6 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
@Extension
class IgdbMetadataFetcher : GameMetadataFetcher {
private val log = LoggerFactory.getLogger(javaClass)
override fun fetchMetadata(gameId: String): GameMetadata {
val findGameByName = APICalypse()
.fields("*")
@@ -71,76 +66,13 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper) {
criticRating = game.aggregatedRating.toInt(),
developedBy = game.involvedCompaniesList.filter { it.developer }.map { it.company.name },
publishedBy = game.involvedCompaniesList.filter { it.publisher }.map { it.company.name },
genres = game.genresList.map { mapGenre(it) },
themes = game.themesList.map { mapTheme(it) },
genres = game.genresList.map { Mapper.genre(it) },
themes = game.themesList.map { Mapper.theme(it) },
screenshotUrls = listOf(),
videoUrls = listOf(),
features = listOf(),
perspectives = listOf()
)
}
private fun mapGenre(genre: proto.Genre): Genre {
return when (genre.slug) {
"pinball" -> Genre.PINBALL
"adventure" -> Genre.ADVENTURE
"indie" -> Genre.INDIE
"arcade" -> Genre.ARCADE
"visual-novel" -> Genre.VISUAL_NOVEL
"card-and-board-game" -> Genre.CARD_AND_BOARD_GAME
"moba" -> Genre.MOBA
"point-and-click" -> Genre.POINT_AND_CLICK
"fighting" -> Genre.FIGHTING
"shooter" -> Genre.SHOOTER
"music" -> Genre.MUSIC
"platform" -> Genre.PLATFORM
"puzzle" -> Genre.PUZZLE
"racing" -> Genre.RACING
"real-time-strategy-rts" -> Genre.REAL_TIME_STRATEGY
"role-playing-rpg" -> Genre.ROLE_PLAYING
"simulator" -> Genre.SIMULATOR
"sport" -> Genre.SPORT
"strategy" -> Genre.STRATEGY
"turn-based-strategy-tbs" -> Genre.TURN_BASED_STRATEGY
"tactical" -> Genre.TACTICAL
"hack-and-slash-beat-em-up" -> Genre.HACK_AND_SLASH_BEAT_EM_UP
"quiz-trivia" -> Genre.QUIZ_TRIVIA
else -> {
log.warn("Unknown genre: {}", genre.slug)
Genre.UNKNOWN
}
}
}
private fun mapTheme(theme: proto.Theme): Theme {
return when (theme.slug) {
"action" -> Theme.ACTION
"fantasy" -> Theme.FANTASY
"horror" -> Theme.HORROR
"sci-fi" -> Theme.SCIENCE_FICTION
"mystery" -> Theme.MYSTERY
"thriller" -> Theme.THRILLER
"survival" -> Theme.SURVIVAL
"historical" -> Theme.HISTORICAL
"stealth" -> Theme.STEALTH
"comedy" -> Theme.COMEDY
"business" -> Theme.BUSINESS
"drama" -> Theme.DRAMA
"non-fiction" -> Theme.NON_FICTION
"sandbox" -> Theme.SANDBOX
"educational" -> Theme.EDUCATIONAL
"kids" -> Theme.KIDS
"open-world" -> Theme.OPEN_WORLD
"warfare" -> Theme.WARFARE
"party" -> Theme.PARTY
"4x-explore-expand-exploit-and-exterminate" -> Theme.FOUR_X
"erotic" -> Theme.EROTIC
"romance" -> Theme.ROMANCE
else -> {
log.warn("Unknown theme: {}", theme.slug)
Theme.UNKNOWN
}
}
}
}
}
@@ -0,0 +1,74 @@
package de.grimsi.gameyfin.plugins.igdb
import de.grimsi.gameyfin.pluginapi.gamemetadata.Genre
import de.grimsi.gameyfin.pluginapi.gamemetadata.Theme
import org.slf4j.LoggerFactory
class Mapper {
companion object {
private val log = LoggerFactory.getLogger(javaClass)
fun genre(genre: proto.Genre): Genre {
return when (genre.slug) {
"pinball" -> Genre.PINBALL
"adventure" -> Genre.ADVENTURE
"indie" -> Genre.INDIE
"arcade" -> Genre.ARCADE
"visual-novel" -> Genre.VISUAL_NOVEL
"card-and-board-game" -> Genre.CARD_AND_BOARD_GAME
"moba" -> Genre.MOBA
"point-and-click" -> Genre.POINT_AND_CLICK
"fighting" -> Genre.FIGHTING
"shooter" -> Genre.SHOOTER
"music" -> Genre.MUSIC
"platform" -> Genre.PLATFORM
"puzzle" -> Genre.PUZZLE
"racing" -> Genre.RACING
"real-time-strategy-rts" -> Genre.REAL_TIME_STRATEGY
"role-playing-rpg" -> Genre.ROLE_PLAYING
"simulator" -> Genre.SIMULATOR
"sport" -> Genre.SPORT
"strategy" -> Genre.STRATEGY
"turn-based-strategy-tbs" -> Genre.TURN_BASED_STRATEGY
"tactical" -> Genre.TACTICAL
"hack-and-slash-beat-em-up" -> Genre.HACK_AND_SLASH_BEAT_EM_UP
"quiz-trivia" -> Genre.QUIZ_TRIVIA
else -> {
log.warn("Unknown genre: {}", genre.slug)
Genre.UNKNOWN
}
}
}
fun theme(theme: proto.Theme): Theme {
return when (theme.slug) {
"action" -> Theme.ACTION
"fantasy" -> Theme.FANTASY
"horror" -> Theme.HORROR
"sci-fi" -> Theme.SCIENCE_FICTION
"mystery" -> Theme.MYSTERY
"thriller" -> Theme.THRILLER
"survival" -> Theme.SURVIVAL
"historical" -> Theme.HISTORICAL
"stealth" -> Theme.STEALTH
"comedy" -> Theme.COMEDY
"business" -> Theme.BUSINESS
"drama" -> Theme.DRAMA
"non-fiction" -> Theme.NON_FICTION
"sandbox" -> Theme.SANDBOX
"educational" -> Theme.EDUCATIONAL
"kids" -> Theme.KIDS
"open-world" -> Theme.OPEN_WORLD
"warfare" -> Theme.WARFARE
"party" -> Theme.PARTY
"4x-explore-expand-exploit-and-exterminate" -> Theme.FOUR_X
"erotic" -> Theme.EROTIC
"romance" -> Theme.ROMANCE
else -> {
log.warn("Unknown theme: {}", theme.slug)
Theme.UNKNOWN
}
}
}
}
}
@@ -1,7 +0,0 @@
package de.grimsi.gameyfin.plugins.igdb.dto
data class TwitchOAuthTokenDto(
val accessToken: String,
val expiresIn: Int,
val tokenType: String
)
+3 -2
View File
@@ -1,5 +1,6 @@
Manifest-Version: 1.0
Plugin-Id: igdb
Plugin-Class: de.grimsi.gameyfin.plugins.igdb.IgdbPlugin
Plugin-Version: 1.0.0-SNAPSHOT
Plugin-Id: igdb
Plugin-Description: IGDB Plugin
Plugin-Version: 1.0.0-alpha1
Plugin-Provider: grimsi