mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 16:20:03 +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 PasswordResetView from "Frontend/views/PasswordResetView";
|
||||||
import EmailConfirmationView from "Frontend/views/EmailConfirmationView";
|
import EmailConfirmationView from "Frontend/views/EmailConfirmationView";
|
||||||
import InvitationRegistrationView from "Frontend/views/InvitationRegistrationView";
|
import InvitationRegistrationView from "Frontend/views/InvitationRegistrationView";
|
||||||
|
import PluginManagement from "Frontend/components/administration/PluginManagement";
|
||||||
|
|
||||||
export const routes = protectRoutes([
|
export const routes = protectRoutes([
|
||||||
{
|
{
|
||||||
@@ -46,6 +47,7 @@ export const routes = protectRoutes([
|
|||||||
{path: 'users', element: <UserManagement/>},
|
{path: 'users', element: <UserManagement/>},
|
||||||
{path: 'sso', element: <SsoManagement/>},
|
{path: 'sso', element: <SsoManagement/>},
|
||||||
{path: 'messages', element: <MessageManagement/>},
|
{path: 'messages', element: <MessageManagement/>},
|
||||||
|
{path: 'plugins', element: <PluginManagement/>},
|
||||||
{path: 'logs', element: <LogManagement/>}
|
{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";
|
import withSideMenu, {MenuItem} from "Frontend/components/general/withSideMenu";
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
@@ -22,6 +22,11 @@ const menuItems: MenuItem[] = [
|
|||||||
url: "messages",
|
url: "messages",
|
||||||
icon: <Envelope/>
|
icon: <Envelope/>
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Plugins",
|
||||||
|
url: "plugins",
|
||||||
|
icon: <Plug/>
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Logs",
|
title: "Logs",
|
||||||
url: "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
|
import java.nio.file.Path
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
class PluginManagerConfig {
|
class PluginManagerConfig(
|
||||||
|
private val pluginConfigRepository: PluginConfigRepository
|
||||||
|
) {
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
private val pluginPath = System.getProperty("pf4j.pluginsDir", "plugins")
|
private val pluginPath = System.getProperty("pf4j.pluginsDir", "plugins")
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun pluginManager() = SpringDevtoolsPluginManager(Path.of(pluginPath))
|
fun pluginManager() = SpringDevtoolsPluginManager(Path.of(pluginPath), pluginConfigRepository)
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent::class)
|
@EventListener(ApplicationReadyEvent::class)
|
||||||
fun loadedPlugins() {
|
fun loadPlugins() {
|
||||||
pluginManager().loadPlugins()
|
pluginManager().loadPlugins()
|
||||||
pluginManager().startPlugins()
|
pluginManager().startPlugins()
|
||||||
log.info { "Loaded plugins: ${pluginManager().plugins.map { it.pluginId }}" }
|
log.info { "Loaded plugins: ${pluginManager().plugins.map { it.pluginId }}" }
|
||||||
|
|||||||
+39
-1
@@ -1,5 +1,7 @@
|
|||||||
package de.grimsi.gameyfin.core.plugins
|
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.CompoundPluginLoader
|
||||||
import org.pf4j.CompoundPluginRepository
|
import org.pf4j.CompoundPluginRepository
|
||||||
import org.pf4j.DefaultPluginLoader
|
import org.pf4j.DefaultPluginLoader
|
||||||
@@ -10,12 +12,18 @@ import org.pf4j.JarPluginLoader
|
|||||||
import org.pf4j.JarPluginRepository
|
import org.pf4j.JarPluginRepository
|
||||||
import org.pf4j.PluginLoader
|
import org.pf4j.PluginLoader
|
||||||
import org.pf4j.PluginRepository
|
import org.pf4j.PluginRepository
|
||||||
|
import org.pf4j.PluginWrapper
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @see https://stackoverflow.com/questions/73654174/my-application-cant-find-the-extension-with-pf4j
|
* @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 {
|
override fun createPluginRepository(): PluginRepository {
|
||||||
return CompoundPluginRepository()
|
return CompoundPluginRepository()
|
||||||
@@ -35,4 +43,34 @@ class SpringDevtoolsPluginManager(path: Path) : DefaultPluginManager(path) {
|
|||||||
.add(jarPluginLoader, this::isNotDevelopment)
|
.add(jarPluginLoader, this::isNotDevelopment)
|
||||||
.add(defaultPluginLoader, 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
|
package de.grimsi.gameyfin.pluginapi.core
|
||||||
|
|
||||||
import org.pf4j.Plugin
|
interface GameyfinPlugin {
|
||||||
|
fun getConfigMetadata(): List<PluginConfigElement>
|
||||||
abstract class GameyfinPlugin(protected val context: PluginContext) : Plugin()
|
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.IGDBWrapper
|
||||||
import com.api.igdb.request.TwitchAuthenticator
|
import com.api.igdb.request.TwitchAuthenticator
|
||||||
import com.api.igdb.request.games
|
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.core.PluginConfigError
|
||||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
|
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
|
||||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataFetcher
|
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataFetcher
|
||||||
@@ -16,16 +18,36 @@ import org.pf4j.PluginWrapper
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import kotlin.collections.filter
|
import kotlin.collections.filter
|
||||||
|
|
||||||
class IgdbPlugin(wrapper: PluginWrapper) : Plugin(wrapper) {
|
class IgdbPlugin(wrapper: PluginWrapper) : Plugin(wrapper), GameyfinPlugin {
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
|
|
||||||
companion object {
|
private val configMetadata: List<PluginConfigElement> = listOf(
|
||||||
val config: IgdbPluginConfig = IgdbPluginConfig(null, null)
|
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() {
|
override fun start() {
|
||||||
authenticate()
|
try {
|
||||||
|
authenticate()
|
||||||
|
} catch (e: PluginConfigError) {
|
||||||
|
log.error { e.message }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stop() {
|
override fun stop() {
|
||||||
@@ -35,8 +57,8 @@ class IgdbPlugin(wrapper: PluginWrapper) : Plugin(wrapper) {
|
|||||||
private fun authenticate() {
|
private fun authenticate() {
|
||||||
log.debug { "Authenticating on Twitch API..." }
|
log.debug { "Authenticating on Twitch API..." }
|
||||||
|
|
||||||
val clientId: String = config.clientId ?: throw PluginConfigError("Twitch Client ID 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 clientSecret: String = config["clientSecret"] ?: throw PluginConfigError("Twitch Client Secret not set")
|
||||||
|
|
||||||
val token = TwitchAuthenticator.requestTwitchToken(clientId, clientSecret)
|
val token = TwitchAuthenticator.requestTwitchToken(clientId, clientSecret)
|
||||||
?: throw PluginConfigError("Failed to authenticate on Twitch API")
|
?: 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