mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
WIP: First "full-stack" implementation of plugin management
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
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";
|
||||
|
||||
export default function PluginManagement() {
|
||||
const [configSaved, setConfigSaved] = useState(false);
|
||||
const [igdbConfigMeta, setIgdbConfigMeta] = useState<any>();
|
||||
const [igdbConfig, setIgdbConfig] = useState<any>();
|
||||
|
||||
useEffect(() => {
|
||||
PluginConfigEndpoint.getConfigMetadata("igdb").then(setIgdbConfigMeta);
|
||||
PluginConfigEndpoint.getConfig("igdb").then(setIgdbConfig);
|
||||
}, []);
|
||||
|
||||
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
|
||||
}}
|
||||
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-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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {LogManagement} from "Frontend/components/administration/LogManagement";
|
||||
import PasswordResetView from "Frontend/views/PasswordResetView";
|
||||
import EmailConfirmationView from "Frontend/views/EmailConfirmationView";
|
||||
import InvitationRegistrationView from "Frontend/views/InvitationRegistrationView";
|
||||
import PluginManagement from "Frontend/components/administration/PluginManagement";
|
||||
|
||||
export const routes = protectRoutes([
|
||||
{
|
||||
@@ -46,6 +47,7 @@ export const routes = protectRoutes([
|
||||
{path: 'users', element: <UserManagement/>},
|
||||
{path: 'sso', element: <SsoManagement/>},
|
||||
{path: 'messages', element: <MessageManagement/>},
|
||||
{path: 'plugins', element: <PluginManagement/>},
|
||||
{path: 'logs', element: <LogManagement/>}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Envelope, GameController, LockKey, Log, Users} from "@phosphor-icons/react";
|
||||
import {Envelope, GameController, LockKey, Log, Plug, Users} from "@phosphor-icons/react";
|
||||
import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
@@ -22,6 +22,11 @@ const menuItems: MenuItem[] = [
|
||||
url: "messages",
|
||||
icon: <Envelope/>
|
||||
},
|
||||
{
|
||||
title: "Plugins",
|
||||
url: "plugins",
|
||||
icon: <Plug/>
|
||||
},
|
||||
{
|
||||
title: "Logs",
|
||||
url: "logs",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.grimsi.gameyfin.core.plugins
|
||||
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@Endpoint
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
class PluginConfigEndpoint(
|
||||
private val pluginConfigService: PluginConfigService
|
||||
) {
|
||||
fun getConfigMetadata(pluginId: String): List<PluginConfigElement> {
|
||||
return pluginConfigService.getConfigMetadata(pluginId)
|
||||
}
|
||||
|
||||
fun getConfig(pluginId: String): Map<String, String?> {
|
||||
return pluginConfigService.getConfig(pluginId)
|
||||
}
|
||||
|
||||
fun setConfigEntries(pluginId: String, config: Map<String, String>) {
|
||||
pluginConfigService.setConfigEntries(pluginId, config)
|
||||
}
|
||||
|
||||
fun setConfigEntry(pluginId: String, key: String, value: String) {
|
||||
pluginConfigService.setConfigEntry(pluginId, key, value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.grimsi.gameyfin.core.plugins
|
||||
|
||||
import de.grimsi.gameyfin.core.security.EncryptionConverter
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Convert
|
||||
import jakarta.persistence.Embeddable
|
||||
import jakarta.persistence.EmbeddedId
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Table
|
||||
import jakarta.validation.constraints.NotNull
|
||||
import java.io.Serializable
|
||||
|
||||
@Entity
|
||||
@Table(name = "plugin_config")
|
||||
data class PluginConfigEntry(
|
||||
@NotNull
|
||||
@EmbeddedId
|
||||
val id: PluginConfigEntryKey,
|
||||
|
||||
@NotNull
|
||||
@Column(name = "`value`")
|
||||
@Convert(converter = EncryptionConverter::class)
|
||||
val value: String
|
||||
)
|
||||
|
||||
@Embeddable
|
||||
data class PluginConfigEntryKey(
|
||||
@Column(name = "plugin_id")
|
||||
val pluginId: String,
|
||||
|
||||
@Column(name = "`key`")
|
||||
val key: String
|
||||
) : Serializable
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.grimsi.gameyfin.core.plugins
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface PluginConfigRepository : JpaRepository<PluginConfigEntry, PluginConfigEntryKey> {
|
||||
fun findAllById_PluginId(pluginId: String): List<PluginConfigEntry>
|
||||
fun findById_PluginIdAndId_Key(pluginId: String, key: String): PluginConfigEntry?
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.grimsi.gameyfin.core.plugins
|
||||
|
||||
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
|
||||
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class PluginConfigService(
|
||||
private val pluginConfigRepository: PluginConfigRepository,
|
||||
private val pluginManager: SpringDevtoolsPluginManager
|
||||
) {
|
||||
|
||||
fun getConfigMetadata(pluginId: String): List<PluginConfigElement> {
|
||||
val plugin = pluginManager.getPlugin(pluginId).plugin as GameyfinPlugin
|
||||
return plugin.getConfigMetadata()
|
||||
}
|
||||
|
||||
fun getConfig(pluginId: String): Map<String, String?> {
|
||||
return pluginConfigRepository.findAllById_PluginId(pluginId).associate { it.id.key to it.value }
|
||||
}
|
||||
|
||||
fun setConfigEntries(pluginId: String, config: Map<String, String>) {
|
||||
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) {
|
||||
val entry = PluginConfigEntry(PluginConfigEntryKey(pluginId, key), value)
|
||||
pluginConfigRepository.save(entry)
|
||||
pluginManager.restart(pluginId)
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,17 @@ import org.springframework.context.event.EventListener
|
||||
import java.nio.file.Path
|
||||
|
||||
@Configuration
|
||||
class PluginManagerConfig {
|
||||
class PluginManagerConfig(
|
||||
private val pluginConfigRepository: PluginConfigRepository
|
||||
) {
|
||||
private val log = KotlinLogging.logger {}
|
||||
private val pluginPath = System.getProperty("pf4j.pluginsDir", "plugins")
|
||||
|
||||
@Bean
|
||||
fun pluginManager() = SpringDevtoolsPluginManager(Path.of(pluginPath))
|
||||
fun pluginManager() = SpringDevtoolsPluginManager(Path.of(pluginPath), pluginConfigRepository)
|
||||
|
||||
@EventListener(ApplicationReadyEvent::class)
|
||||
fun loadedPlugins() {
|
||||
fun loadPlugins() {
|
||||
pluginManager().loadPlugins()
|
||||
pluginManager().startPlugins()
|
||||
log.info { "Loaded plugins: ${pluginManager().plugins.map { it.pluginId }}" }
|
||||
|
||||
+39
-1
@@ -1,5 +1,7 @@
|
||||
package de.grimsi.gameyfin.core.plugins
|
||||
|
||||
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.pf4j.CompoundPluginLoader
|
||||
import org.pf4j.CompoundPluginRepository
|
||||
import org.pf4j.DefaultPluginLoader
|
||||
@@ -10,12 +12,18 @@ import org.pf4j.JarPluginLoader
|
||||
import org.pf4j.JarPluginRepository
|
||||
import org.pf4j.PluginLoader
|
||||
import org.pf4j.PluginRepository
|
||||
import org.pf4j.PluginWrapper
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* @see https://stackoverflow.com/questions/73654174/my-application-cant-find-the-extension-with-pf4j
|
||||
*/
|
||||
class SpringDevtoolsPluginManager(path: Path) : DefaultPluginManager(path) {
|
||||
class SpringDevtoolsPluginManager(
|
||||
path: Path,
|
||||
private val pluginConfigRepository: PluginConfigRepository
|
||||
) : DefaultPluginManager(path) {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
override fun createPluginRepository(): PluginRepository {
|
||||
return CompoundPluginRepository()
|
||||
@@ -35,4 +43,34 @@ class SpringDevtoolsPluginManager(path: Path) : DefaultPluginManager(path) {
|
||||
.add(jarPluginLoader, this::isNotDevelopment)
|
||||
.add(defaultPluginLoader, this::isNotDevelopment)
|
||||
}
|
||||
|
||||
override fun loadPluginFromPath(pluginPath: Path?): PluginWrapper? {
|
||||
val pluginWrapper = super.loadPluginFromPath(pluginPath)
|
||||
|
||||
// Inject config after loading, before starting
|
||||
if (pluginWrapper != null) {
|
||||
configurePlugin(pluginWrapper)
|
||||
}
|
||||
|
||||
return pluginWrapper
|
||||
}
|
||||
|
||||
fun restart(pluginId: String) {
|
||||
val plugin = getPlugin(pluginId)?.plugin ?: return
|
||||
plugin.stop()
|
||||
(plugin as GameyfinPlugin).loadConfig(getConfig(pluginId))
|
||||
plugin.start()
|
||||
}
|
||||
|
||||
private fun configurePlugin(pluginWrapper: PluginWrapper) {
|
||||
val plugin = pluginWrapper.plugin
|
||||
if (plugin is GameyfinPlugin) {
|
||||
val config = getConfig(pluginWrapper.pluginId)
|
||||
plugin.loadConfig(config)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfig(pluginId: String): Map<String, String?> {
|
||||
return pluginConfigRepository.findAllById_PluginId(pluginId).map { it.id.key to it.value }.toMap()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.grimsi.gameyfin.pluginapi.core
|
||||
|
||||
import org.pf4j.Plugin
|
||||
|
||||
abstract class GameyfinPlugin(protected val context: PluginContext) : Plugin()
|
||||
interface GameyfinPlugin {
|
||||
fun getConfigMetadata(): List<PluginConfigElement>
|
||||
fun getCurrentConfig(): Map<String, String?>
|
||||
fun loadConfig(config: Map<String, String?>)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.grimsi.gameyfin.pluginapi.core
|
||||
|
||||
data class PluginConfigElement(
|
||||
val key: String,
|
||||
val name: String,
|
||||
val description: String
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
package de.grimsi.gameyfin.pluginapi.core
|
||||
|
||||
import org.pf4j.RuntimeMode
|
||||
|
||||
class PluginContext(private val runtimeMode: RuntimeMode) {
|
||||
fun getRuntimeMode(): RuntimeMode {
|
||||
return runtimeMode
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import com.api.igdb.apicalypse.APICalypse
|
||||
import com.api.igdb.request.IGDBWrapper
|
||||
import com.api.igdb.request.TwitchAuthenticator
|
||||
import com.api.igdb.request.games
|
||||
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
|
||||
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
|
||||
@@ -16,16 +18,36 @@ import org.pf4j.PluginWrapper
|
||||
import java.time.Instant
|
||||
import kotlin.collections.filter
|
||||
|
||||
class IgdbPlugin(wrapper: PluginWrapper) : Plugin(wrapper) {
|
||||
class IgdbPlugin(wrapper: PluginWrapper) : Plugin(wrapper), GameyfinPlugin {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
companion object {
|
||||
val config: IgdbPluginConfig = IgdbPluginConfig(null, null)
|
||||
private val configMetadata: List<PluginConfigElement> = listOf(
|
||||
PluginConfigElement("clientId", "Twitch client ID", "Your Twitch Client ID"),
|
||||
PluginConfigElement("clientSecret", "Twitch client secret", "Your Twitch Client Secret")
|
||||
)
|
||||
|
||||
private var config: Map<String, String?> = configMetadata.associate { it.key to null }
|
||||
|
||||
override fun getConfigMetadata(): List<PluginConfigElement> {
|
||||
return configMetadata
|
||||
}
|
||||
|
||||
override fun getCurrentConfig(): Map<String, String?> {
|
||||
return config
|
||||
}
|
||||
|
||||
override fun loadConfig(config: Map<String, String?>) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
|
||||
override fun start() {
|
||||
authenticate()
|
||||
try {
|
||||
authenticate()
|
||||
} catch (e: PluginConfigError) {
|
||||
log.error { e.message }
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
@@ -35,8 +57,8 @@ class IgdbPlugin(wrapper: PluginWrapper) : Plugin(wrapper) {
|
||||
private fun authenticate() {
|
||||
log.debug { "Authenticating on Twitch API..." }
|
||||
|
||||
val clientId: String = config.clientId ?: throw PluginConfigError("Twitch Client ID not set")
|
||||
val clientSecret: String = config.clientSecret ?: throw PluginConfigError("Twitch Client Secret not set")
|
||||
val clientId: String = config["clientId"] ?: throw PluginConfigError("Twitch Client ID not set")
|
||||
val clientSecret: String = config["clientSecret"] ?: throw PluginConfigError("Twitch Client Secret not set")
|
||||
|
||||
val token = TwitchAuthenticator.requestTwitchToken(clientId, clientSecret)
|
||||
?: throw PluginConfigError("Failed to authenticate on Twitch API")
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package de.grimsi.gameyfin.plugins.igdb
|
||||
|
||||
data class IgdbPluginConfig(
|
||||
val clientId: String?,
|
||||
val clientSecret: String?
|
||||
)
|
||||
Reference in New Issue
Block a user