Extend plugin config validation

Cache validation results
Show field-level errors in UI
Enable manual revalidation
Move PluginConfigService and PluginManagementService into PluginService
This commit is contained in:
grimsi
2025-05-19 12:21:42 +02:00
parent 08c41265c8
commit d9fef0f30c
23 changed files with 240 additions and 130 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="UI debug" type="JavascriptDebugType" engineId="37cae5b9-e8b2-4949-9172-aafa37fbc09c" uri="http://localhost:8080"> <configuration default="false" name="UI debug" type="JavascriptDebugType" uri="http://localhost:8080">
<method v="2" /> <method v="2" />
</configuration> </configuration>
</component> </component>
@@ -1,4 +1,4 @@
import {Button, Card, Chip, Skeleton, Tooltip, useDisclosure} from "@heroui/react"; import {Button, Card, Chip, Tooltip, useDisclosure} from "@heroui/react";
import { import {
CheckCircle, CheckCircle,
IconContext, IconContext,
@@ -16,31 +16,24 @@ import {
XCircle XCircle
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import PluginState from "Frontend/generated/org/pf4j/PluginState"; import PluginState from "Frontend/generated/org/pf4j/PluginState";
import React, {ReactNode, useEffect, useState} from "react"; import React, {ReactNode} from "react";
import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal"; import PluginDetailsModal from "Frontend/components/general/modals/PluginDetailsModal";
import PluginLogo from "Frontend/components/general/PluginLogo"; import PluginLogo from "Frontend/components/general/PluginLogo";
import PluginTrustLevel from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginTrustLevel"; import PluginTrustLevel from "Frontend/generated/de/grimsi/gameyfin/core/plugins/management/PluginTrustLevel";
import PluginConfigValidationResult
from "Frontend/generated/de/grimsi/gameyfin/core/plugins/config/PluginConfigValidationResult";
import {PluginEndpoint} from "Frontend/generated/endpoints"; import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto"; import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto";
import PluginConfigValidationResult
from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigValidationResult";
import PluginConfigValidationResultType
from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigValidationResultType";
export function PluginManagementCard({plugin}: { plugin: PluginDto }) { export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
const pluginDetailsModal = useDisclosure(); const pluginDetailsModal = useDisclosure();
const [configValidationResult, setConfigValidationResult] = useState<PluginConfigValidationResult | undefined>(undefined);
useEffect(() => {
PluginEndpoint.validatePluginConfig(plugin.id).then((response: PluginConfigValidationResult) => {
setConfigValidationResult(response);
});
}, [pluginDetailsModal.isOpen, plugin.state]);
function borderColor(state: PluginState | undefined, trustLevel: PluginTrustLevel | undefined): "success" | "warning" | "danger" | "default" { function borderColor(state: PluginState | undefined, trustLevel: PluginTrustLevel | undefined): "success" | "warning" | "danger" | "default" {
if (trustLevel === PluginTrustLevel.UNTRUSTED) return "danger"; if (trustLevel === PluginTrustLevel.UNTRUSTED) return "danger";
if (isDisabled(state)) return "warning"; if (isDisabled(state)) return "warning";
if (configValidationResult === undefined) return "default";
if (!configValidationResult) return "danger";
return stateToColor(state); return stateToColor(state);
} }
@@ -76,14 +69,14 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
} }
function configValidationResultToChip(validationResult: PluginConfigValidationResult | undefined): ReactNode { function configValidationResultToChip(validationResult: PluginConfigValidationResult | undefined): ReactNode {
switch (validationResult) { switch (validationResult?.result) {
case PluginConfigValidationResult.VALID: case PluginConfigValidationResultType.VALID:
return <Tooltip content="Config valid" placement="bottom" color="foreground"> return <Tooltip content="Config valid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="success"> <Chip size="sm" radius="sm" className="text-xs" color="success">
<CheckCircle/> <CheckCircle/>
</Chip> </Chip>
</Tooltip> </Tooltip>
case PluginConfigValidationResult.INVALID: case PluginConfigValidationResultType.INVALID:
return <Tooltip content="Config invalid" placement="bottom" color="foreground"> return <Tooltip content="Config invalid" placement="bottom" color="foreground">
<Chip size="sm" radius="sm" className="text-xs" color="danger"> <Chip size="sm" radius="sm" className="text-xs" color="danger">
<WarningCircle/> <WarningCircle/>
@@ -173,10 +166,7 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
{stateToIcon(plugin.state)} {stateToIcon(plugin.state)}
</Tooltip> </Tooltip>
</Chip> </Chip>
{configValidationResult === undefined ? {configValidationResultToChip(plugin.configValidation)}
<Skeleton className="rounded-md h-6 w-9"/> :
configValidationResultToChip(configValidationResult)
}
</div> </div>
</div> </div>
</Card> </Card>
@@ -4,7 +4,7 @@ import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
import {XCircle} from "@phosphor-icons/react"; import {XCircle} from "@phosphor-icons/react";
// @ts-ignore // @ts-ignore
const Input = ({label, ...props}) => { const Input = ({label, showErrorUntouched = false, ...props}) => {
// @ts-ignore // @ts-ignore
const [field, meta] = useField(props); const [field, meta] = useField(props);
@@ -15,10 +15,10 @@ const Input = ({label, ...props}) => {
{...field} {...field}
id={label} id={label}
label={label} label={label}
isInvalid={meta.touched && !!meta.error} isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
/> />
<div className="min-h-6 text-danger"> <div className="min-h-6 text-danger">
{meta.touched && meta.error && meta.error.trim().length > 0 && ( {(meta.touched || showErrorUntouched) && meta.error && meta.error.trim().length > 0 && (
<SmallInfoField icon={XCircle} message={meta.error}/> <SmallInfoField icon={XCircle} message={meta.error}/>
)} )}
</div> </div>
@@ -1,5 +1,5 @@
import React from "react"; import React, {useState} from "react";
import {addToast, Button, Link, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react"; import {addToast, Button, Link, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Tooltip} from "@heroui/react";
import {Form, Formik} from "formik"; import {Form, Formik} from "formik";
import PluginConfigElement from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigElement"; import PluginConfigElement from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigElement";
import Input from "Frontend/components/general/input/Input"; import Input from "Frontend/components/general/input/Input";
@@ -8,6 +8,7 @@ import Markdown from "react-markdown";
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
import {PluginEndpoint} from "Frontend/generated/endpoints"; import {PluginEndpoint} from "Frontend/generated/endpoints";
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto"; import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto";
import {ArrowClockwise} from "@phosphor-icons/react";
interface PluginDetailsModalProps { interface PluginDetailsModalProps {
plugin: PluginDto; plugin: PluginDto;
@@ -16,6 +17,8 @@ interface PluginDetailsModalProps {
} }
export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: PluginDetailsModalProps) { export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: PluginDetailsModalProps) {
const [configValidated, setConfigValidated] = useState<boolean>(false);
async function saveConfig(values: Record<string, string>) { async function saveConfig(values: Record<string, string>) {
await PluginEndpoint.updateConfig(plugin.id, values); await PluginEndpoint.updateConfig(plugin.id, values);
addToast({ addToast({
@@ -30,13 +33,14 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi
<ModalContent> <ModalContent>
{(onClose) => ( {(onClose) => (
<Formik initialValues={plugin.config} <Formik initialValues={plugin.config}
initialErrors={plugin.configValidation?.errors}
enableReinitialize={true} enableReinitialize={true}
onSubmit={async (values: any) => { onSubmit={async (values: any) => {
await saveConfig(values); await saveConfig(values);
onClose(); onClose();
}} }}
> >
{(formik: { isSubmitting: any; }) => ( {(formik: any) => (
<Form> <Form>
<ModalHeader className="flex flex-col gap-1"> <ModalHeader className="flex flex-col gap-1">
Plugin configuration for {plugin.name} Plugin configuration for {plugin.name}
@@ -88,10 +92,33 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi
>{plugin.description}</Markdown> >{plugin.description}</Markdown>
</div> </div>
<h4 className="text-l font-bold mt-4">Configuration</h4> <div className="flex flex-row items-center mt-4 gap-2">
<h4 className="text-l font-bold">Configuration</h4>
<div className="flex-1"/>
{(plugin.configMetadata && plugin.configMetadata.length > 0) && <>
{configValidated &&
<p className="text-small text-success">Validation successful</p>}
<Tooltip content="Re-validate configuration" placement="bottom"
color="foreground">
<Button isIconOnly variant="light" size="sm"
onPress={async () => {
setConfigValidated(false);
let result = await PluginEndpoint.validateNewConfig(plugin.id, formik.values)
if (result.errors) formik.setErrors(result.errors);
else {
setConfigValidated(true);
setTimeout(() => setConfigValidated(false), 5000);
}
}}>
<ArrowClockwise/>
</Button>
</Tooltip>
</>}
</div>
{(plugin.configMetadata && plugin.configMetadata.length > 0) ? {(plugin.configMetadata && plugin.configMetadata.length > 0) ?
plugin.configMetadata.map((entry: PluginConfigElement) => ( plugin.configMetadata.map((entry: PluginConfigElement) => (
<Input key={entry.key} name={entry.key} label={entry.name} <Input key={entry.key} name={entry.key} label={entry.name}
showErrorUntouched={true}
type={entry.secret ? "password" : "text"}/> type={entry.secret ? "password" : "text"}/>
)) : "This plugin has no configuration options." )) : "This plugin has no configuration options."
} }
@@ -5,6 +5,7 @@ import de.grimsi.gameyfin.config.dto.ConfigEntryDto
import de.grimsi.gameyfin.config.dto.ConfigUpdateDto import de.grimsi.gameyfin.config.dto.ConfigUpdateDto
import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.users.util.isAdmin import de.grimsi.gameyfin.users.util.isAdmin
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
@@ -16,6 +17,9 @@ import reactor.core.publisher.Flux
class ConfigEndpoint( class ConfigEndpoint(
private val configService: ConfigService private val configService: ConfigService
) { ) {
companion object {
val log = KotlinLogging.logger { }
}
/** CRUD endpoints for admins **/ /** CRUD endpoints for admins **/
@@ -15,12 +15,19 @@ import java.io.Serializable
class ConfigService( class ConfigService(
private val appConfigRepository: ConfigRepository private val appConfigRepository: ConfigRepository
) { ) {
private val log = KotlinLogging.logger {} companion object {
private val log = KotlinLogging.logger {}
}
private val configUpdates = Sinks.many().multicast().onBackpressureBuffer<ConfigUpdateDto>() private val configUpdates = Sinks.many().multicast().onBackpressureBuffer<ConfigUpdateDto>()
fun subscribe(): Flux<ConfigUpdateDto> { fun subscribe(): Flux<ConfigUpdateDto> {
log.debug { "New subscription for configUpdates (#${configUpdates.currentSubscriberCount()})" }
return configUpdates.asFlux() return configUpdates.asFlux()
.doOnSubscribe { log.debug { "Subscriber added to configUpdates [${configUpdates.currentSubscriberCount()}]" } }
.doFinally {
log.debug { "Subscriber removed from configUpdates with signal type $it [${configUpdates.currentSubscriberCount()}]" }
}
} }
/** /**
@@ -2,9 +2,9 @@ package de.grimsi.gameyfin.core.plugins
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.core.plugins.config.PluginConfigValidationResult
import de.grimsi.gameyfin.core.plugins.dto.PluginUpdateDto import de.grimsi.gameyfin.core.plugins.dto.PluginUpdateDto
import de.grimsi.gameyfin.core.plugins.management.PluginManagementService import de.grimsi.gameyfin.core.plugins.management.PluginService
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import de.grimsi.gameyfin.users.util.isAdmin import de.grimsi.gameyfin.users.util.isAdmin
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
@@ -15,28 +15,31 @@ import reactor.core.publisher.Flux
@Endpoint @Endpoint
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
class PluginEndpoint( class PluginEndpoint(
private val pluginManagementService: PluginManagementService private val pluginService: PluginService
) { ) {
@PermitAll @PermitAll
fun subscribe(): Flux<PluginUpdateDto> { fun subscribe(): Flux<PluginUpdateDto> {
val user = SecurityContextHolder.getContext().authentication.principal as UserDetails val user = SecurityContextHolder.getContext().authentication.principal as UserDetails
return if (user.isAdmin()) pluginManagementService.subscribe() return if (user.isAdmin()) pluginService.subscribe()
else Flux.empty() else Flux.empty()
} }
fun getAll() = pluginManagementService.getAll() fun getAll() = pluginService.getAll()
fun enablePlugin(pluginId: String) = pluginManagementService.enablePlugin(pluginId) fun enablePlugin(pluginId: String) = pluginService.enablePlugin(pluginId)
fun disablePlugin(pluginId: String) = pluginManagementService.disablePlugin(pluginId) fun disablePlugin(pluginId: String) = pluginService.disablePlugin(pluginId)
fun setPluginPriorities(pluginPriorities: Map<String, Int>) = fun setPluginPriorities(pluginPriorities: Map<String, Int>) =
pluginManagementService.setPluginPriorities(pluginPriorities) pluginService.setPluginPriorities(pluginPriorities)
fun validatePluginConfig(pluginId: String): PluginConfigValidationResult = fun validatePluginConfig(pluginId: String): PluginConfigValidationResult =
pluginManagementService.validatePluginConfig(pluginId) pluginService.validatePluginConfig(pluginId, true)
fun validateNewConfig(pluginId: String, config: Map<String, String>): PluginConfigValidationResult =
pluginService.validatePluginConfig(pluginId, config)
fun updateConfig(pluginId: String, updatedConfig: Map<String, String>) = fun updateConfig(pluginId: String, updatedConfig: Map<String, String>) =
pluginManagementService.updateConfig(pluginId, updatedConfig) pluginService.updateConfig(pluginId, updatedConfig)
} }
@@ -1,10 +1,7 @@
package de.grimsi.gameyfin.core.plugins.config package de.grimsi.gameyfin.core.plugins.config
import de.grimsi.gameyfin.core.plugins.management.GameyfinPluginManager import de.grimsi.gameyfin.core.plugins.management.GameyfinPluginManager
import de.grimsi.gameyfin.pluginapi.core.Configurable
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.pf4j.PluginWrapper
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@Service @Service
@@ -13,24 +10,9 @@ class PluginConfigService(
private val pluginManager: GameyfinPluginManager private val pluginManager: GameyfinPluginManager
) { ) {
private val log = KotlinLogging.logger {} companion object {
private val log = KotlinLogging.logger {}
fun getConfigMetadata(pluginWrapper: PluginWrapper): List<PluginConfigElement> {
log.debug { "Getting config metadata for plugin ${pluginWrapper.pluginId}" }
val plugin = pluginWrapper.plugin
if (plugin !is Configurable) return emptyList()
return plugin.configMetadata
} }
fun getConfig(pluginWrapper: PluginWrapper): Map<String, String?> {
log.debug { "Getting config for plugin ${pluginWrapper.pluginId}" }
return pluginConfigRepository.findAllById_PluginId(pluginWrapper.pluginId).associate { it.id.key to it.value }
}
fun updateConfig(pluginId: String, config: Map<String, String>) {
log.debug { "Setting config entries for plugin $pluginId" }
val entries = config.map { PluginConfigEntry(PluginConfigEntryKey(pluginId, it.key), it.value) }
pluginConfigRepository.saveAll(entries)
pluginManager.restart(pluginId)
}
} }
@@ -1,7 +0,0 @@
package de.grimsi.gameyfin.core.plugins.config
enum class PluginConfigValidationResult {
VALID,
INVALID,
UNKNWOWN,
}
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.core.plugins.dto
import de.grimsi.gameyfin.core.plugins.management.PluginTrustLevel import de.grimsi.gameyfin.core.plugins.management.PluginTrustLevel
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import org.pf4j.PluginState import org.pf4j.PluginState
data class PluginDto( data class PluginDto(
@@ -18,6 +19,7 @@ data class PluginDto(
val state: PluginState, val state: PluginState,
val configMetadata: List<PluginConfigElement>? = null, val configMetadata: List<PluginConfigElement>? = null,
val config: Map<String, String?>? = null, val config: Map<String, String?>? = null,
val configValidation: PluginConfigValidationResult? = null,
val priority: Int, val priority: Int,
val trustLevel: PluginTrustLevel, val trustLevel: PluginTrustLevel,
) )
@@ -1,6 +1,7 @@
package de.grimsi.gameyfin.core.plugins.dto package de.grimsi.gameyfin.core.plugins.dto
import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonInclude
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import org.pf4j.PluginState import org.pf4j.PluginState
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
@@ -8,5 +9,6 @@ data class PluginUpdateDto(
val id: String, val id: String,
val state: PluginState? = null, val state: PluginState? = null,
val config: Map<String, String?>? = null, val config: Map<String, String?>? = null,
val configValidation: PluginConfigValidationResult? = null,
val priority: Int? = null val priority: Int? = null
) )
@@ -1,8 +1,9 @@
package de.grimsi.gameyfin.core.plugins.management package de.grimsi.gameyfin.core.plugins.management
import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository
import de.grimsi.gameyfin.core.plugins.config.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.Configurable import de.grimsi.gameyfin.pluginapi.core.Configurable
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResultType
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.pf4j.* import org.pf4j.*
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@@ -34,9 +35,9 @@ class GameyfinPluginManager(
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
private val publicKey: PublicKey = loadPluginSignaturePublicKey() private val publicKey: PublicKey = loadPluginSignaturePublicKey()
// This took me way too long to figure out...
// But I learned a lot about Kotlin and Java interoperability in the process
init { init {
// This took me way too long to figure out...
// But I learned a lot about Kotlin and Java interoperability in the process
pluginStatusProvider = dbPluginStatusProvider pluginStatusProvider = dbPluginStatusProvider
pluginStateListeners.add { event -> pluginStateListeners.add { event ->
@@ -143,7 +144,7 @@ class GameyfinPluginManager(
} }
// Validate config before starting the plugin // Validate config before starting the plugin
if (validatePluginConfig(pluginId) == PluginConfigValidationResult.INVALID) { if (validatePluginConfig(pluginId).result == PluginConfigValidationResultType.INVALID) {
log.warn { "Plugin $pluginId has invalid configuration" } log.warn { "Plugin $pluginId has invalid configuration" }
val pluginWrapper = getPlugin(pluginId) val pluginWrapper = getPlugin(pluginId)
@@ -171,14 +172,28 @@ class GameyfinPluginManager(
val plugin = try { val plugin = try {
getPlugin(pluginId)?.plugin getPlugin(pluginId)?.plugin
} catch (_: NoClassDefFoundError) { } catch (_: NoClassDefFoundError) {
return PluginConfigValidationResult.UNKNWOWN return PluginConfigValidationResult(PluginConfigValidationResultType.UNKNWOWN)
} }
if (plugin !is Configurable || plugin.validateConfig()) { if (plugin !is Configurable) {
return PluginConfigValidationResult.VALID return PluginConfigValidationResult(PluginConfigValidationResultType.VALID)
} }
return PluginConfigValidationResult.INVALID return plugin.validateConfig()
}
fun validatePluginConfig(pluginId: String, configToValidate: Map<String, String>): PluginConfigValidationResult {
val plugin = try {
getPlugin(pluginId)?.plugin
} catch (_: NoClassDefFoundError) {
return PluginConfigValidationResult(PluginConfigValidationResultType.UNKNWOWN)
}
if (plugin !is Configurable) {
return PluginConfigValidationResult(PluginConfigValidationResultType.VALID)
}
return plugin.validateConfig(configToValidate)
} }
fun getExtensionTypeClasses(pluginId: String): List<Class<ExtensionPoint>> { fun getExtensionTypeClasses(pluginId: String): List<Class<ExtensionPoint>> {
@@ -1,10 +1,16 @@
package de.grimsi.gameyfin.core.plugins.management package de.grimsi.gameyfin.core.plugins.management
import de.grimsi.gameyfin.core.plugins.config.PluginConfigEntry
import de.grimsi.gameyfin.core.plugins.config.PluginConfigEntryKey
import de.grimsi.gameyfin.core.plugins.config.PluginConfigRepository
import de.grimsi.gameyfin.core.plugins.config.PluginConfigService import de.grimsi.gameyfin.core.plugins.config.PluginConfigService
import de.grimsi.gameyfin.core.plugins.config.PluginConfigValidationResult
import de.grimsi.gameyfin.core.plugins.dto.PluginDto import de.grimsi.gameyfin.core.plugins.dto.PluginDto
import de.grimsi.gameyfin.core.plugins.dto.PluginUpdateDto import de.grimsi.gameyfin.core.plugins.dto.PluginUpdateDto
import de.grimsi.gameyfin.pluginapi.core.Configurable
import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin import de.grimsi.gameyfin.pluginapi.core.GameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import io.github.oshai.kotlinlogging.KotlinLogging
import org.pf4j.ExtensionPoint import org.pf4j.ExtensionPoint
import org.pf4j.PluginWrapper import org.pf4j.PluginWrapper
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@@ -13,21 +19,32 @@ import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
@Service @Service
class PluginManagementService( class PluginService(
private val pluginManager: GameyfinPluginManager, private val pluginManager: GameyfinPluginManager,
private val pluginConfigService: PluginConfigService, private val pluginConfigService: PluginConfigService,
private val pluginManagementRepository: PluginManagementRepository, private val pluginManagementRepository: PluginManagementRepository,
private val pluginConfigRepository: PluginConfigRepository
) { ) {
companion object {
private val log = KotlinLogging.logger {}
}
private val pluginUpdates = Sinks.many().multicast().onBackpressureBuffer<PluginUpdateDto>() private val pluginUpdates = Sinks.many().multicast().onBackpressureBuffer<PluginUpdateDto>()
private val pluginConfigValidationCache = mutableMapOf<String, PluginConfigValidationResult>()
init { init {
pluginManager.addPluginStateListener { pluginManager.addPluginStateListener { event ->
pluginUpdates.tryEmitNext(PluginUpdateDto(id = it.plugin.pluginId, state = it.pluginState)) pluginUpdates.tryEmitNext(PluginUpdateDto(id = event.plugin.pluginId, state = event.pluginState))
} }
} }
fun subscribe(): Flux<PluginUpdateDto> { fun subscribe(): Flux<PluginUpdateDto> {
log.debug { "New subscription for pluginUpdates" }
return pluginUpdates.asFlux() return pluginUpdates.asFlux()
.doOnSubscribe { log.debug { "Subscriber added to pluginUpdates [${pluginUpdates.currentSubscriberCount()}]" } }
.doFinally {
log.debug { "Subscriber removed from pluginUpdates with signal type $it [${pluginUpdates.currentSubscriberCount()}]" }
}
} }
fun getSupportedPluginTypes(): List<String> { fun getSupportedPluginTypes(): List<String> {
@@ -59,16 +76,6 @@ class PluginManagementService(
pluginManager.disablePlugin(pluginId) pluginManager.disablePlugin(pluginId)
} }
fun validatePluginConfig(pluginId: String): PluginConfigValidationResult {
return pluginManager.validatePluginConfig(pluginId)
}
fun updateConfig(pluginId: String, updatedConfig: Map<String, String>) {
pluginConfigService.updateConfig(pluginId, updatedConfig)
val update = PluginUpdateDto(pluginId, config = updatedConfig)
pluginUpdates.tryEmitNext(update)
}
fun setPluginPriorities(pluginPriorities: Map<String, Int>) { fun setPluginPriorities(pluginPriorities: Map<String, Int>) {
pluginPriorities.forEach { (pluginId, priority) -> pluginPriorities.forEach { (pluginId, priority) ->
val pluginManagementEntry = getPluginManagementEntry(pluginId) val pluginManagementEntry = getPluginManagementEntry(pluginId)
@@ -82,6 +89,52 @@ class PluginManagementService(
return plugin.getLogo() return plugin.getLogo()
} }
fun getConfigMetadata(pluginWrapper: PluginWrapper): List<PluginConfigElement> {
log.debug { "Getting config metadata for plugin ${pluginWrapper.pluginId}" }
val plugin = pluginWrapper.plugin
if (plugin !is Configurable) return emptyList()
return plugin.configMetadata
}
fun getConfig(pluginWrapper: PluginWrapper): Map<String, String?> {
log.debug { "Getting config for plugin ${pluginWrapper.pluginId}" }
return pluginConfigRepository.findAllById_PluginId(pluginWrapper.pluginId).associate { it.id.key to it.value }
}
fun updateConfig(pluginId: String, config: Map<String, String>) {
log.debug { "Setting config entries for plugin $pluginId" }
val entries = config.map { PluginConfigEntry(PluginConfigEntryKey(pluginId, it.key), it.value) }
// Persist new config
pluginConfigRepository.saveAll(entries)
// Restart plugin to apply new config
pluginManager.restart(pluginId)
// Validate new config
val result = validatePluginConfig(pluginId, true)
// Emit update event
val update = PluginUpdateDto(pluginId, config = config, configValidation = result)
pluginUpdates.tryEmitNext(update)
}
fun validatePluginConfig(pluginId: String, forceRevalidation: Boolean = false): PluginConfigValidationResult {
if (forceRevalidation || !pluginConfigValidationCache.containsKey(pluginId)) {
log.debug { "Validating config for plugin $pluginId" }
val result = pluginManager.validatePluginConfig(pluginId)
pluginConfigValidationCache[pluginId] = result
return result
} else {
log.debug { "Using cached validation result for plugin $pluginId" }
return pluginConfigValidationCache[pluginId]!!
}
}
fun validatePluginConfig(pluginId: String, configToValidate: Map<String, String>): PluginConfigValidationResult {
return pluginManager.validatePluginConfig(pluginId, configToValidate)
}
private fun toDto(pluginWrapper: PluginWrapper): PluginDto { private fun toDto(pluginWrapper: PluginWrapper): PluginDto {
val pluginManagementEntry = getPluginManagementEntry(pluginWrapper.pluginId) val pluginManagementEntry = getPluginManagementEntry(pluginWrapper.pluginId)
@@ -108,8 +161,9 @@ class PluginManagementService(
url = descriptor.pluginUrl, url = descriptor.pluginUrl,
hasLogo = hasLogo, hasLogo = hasLogo,
state = pluginWrapper.pluginState, state = pluginWrapper.pluginState,
configMetadata = pluginConfigService.getConfigMetadata(pluginWrapper), configMetadata = getConfigMetadata(pluginWrapper),
config = pluginConfigService.getConfig(pluginWrapper), config = getConfig(pluginWrapper),
configValidation = validatePluginConfig(descriptor.pluginId),
priority = pluginManagementEntry.priority, priority = pluginManagementEntry.priority,
trustLevel = pluginManagementEntry.trustLevel trustLevel = pluginManagementEntry.trustLevel
) )
@@ -3,7 +3,7 @@ package de.grimsi.gameyfin.games
import de.grimsi.gameyfin.core.alphaNumeric import de.grimsi.gameyfin.core.alphaNumeric
import de.grimsi.gameyfin.core.filterValuesNotNull import de.grimsi.gameyfin.core.filterValuesNotNull
import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry import de.grimsi.gameyfin.core.plugins.management.PluginManagementEntry
import de.grimsi.gameyfin.core.plugins.management.PluginManagementService import de.grimsi.gameyfin.core.plugins.management.PluginService
import de.grimsi.gameyfin.core.replaceRomanNumerals import de.grimsi.gameyfin.core.replaceRomanNumerals
import de.grimsi.gameyfin.games.dto.GameDto import de.grimsi.gameyfin.games.dto.GameDto
import de.grimsi.gameyfin.games.dto.GameMetadataDto import de.grimsi.gameyfin.games.dto.GameMetadataDto
@@ -28,7 +28,7 @@ import java.nio.file.Path
@Service @Service
class GameService( class GameService(
private val pluginManager: PluginManager, private val pluginManager: PluginManager,
private val pluginManagementService: PluginManagementService, private val pluginService: PluginService,
private val gameRepository: GameRepository, private val gameRepository: GameRepository,
private val companyService: CompanyService private val companyService: CompanyService
) { ) {
@@ -179,11 +179,11 @@ class GameService(
// Cache the plugin management entries for each provider // Cache the plugin management entries for each provider
val providerToManagementEntry = val providerToManagementEntry =
results.entries.associate { it.key to pluginManagementService.getPluginManagementEntry(it.key.javaClass) } results.entries.associate { it.key to pluginService.getPluginManagementEntry(it.key.javaClass) }
// Sort results by plugin priority // Sort results by plugin priority
val sortedResults = results.entries.sortedByDescending { val sortedResults = results.entries.sortedByDescending {
pluginManagementService.getPluginManagementEntry(it.key.javaClass).priority pluginService.getPluginManagementEntry(it.key.javaClass).priority
} }
sortedResults.forEach { (provider, metadata) -> sortedResults.forEach { (provider, metadata) ->
@@ -3,7 +3,7 @@ package de.grimsi.gameyfin.media
import de.grimsi.gameyfin.core.Role import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.core.Utils import de.grimsi.gameyfin.core.Utils
import de.grimsi.gameyfin.core.annotations.DynamicPublicAccess import de.grimsi.gameyfin.core.annotations.DynamicPublicAccess
import de.grimsi.gameyfin.core.plugins.management.PluginManagementService import de.grimsi.gameyfin.core.plugins.management.PluginService
import de.grimsi.gameyfin.games.entities.Image import de.grimsi.gameyfin.games.entities.Image
import de.grimsi.gameyfin.games.entities.ImageType import de.grimsi.gameyfin.games.entities.ImageType
import de.grimsi.gameyfin.users.UserService import de.grimsi.gameyfin.users.UserService
@@ -24,7 +24,7 @@ import org.springframework.web.multipart.MultipartFile
class ImageEndpoint( class ImageEndpoint(
private val imageService: ImageService, private val imageService: ImageService,
private val userService: UserService, private val userService: UserService,
private val pluginManagementService: PluginManagementService private val pluginService: PluginService
) { ) {
@GetMapping("/screenshot/{id}") @GetMapping("/screenshot/{id}")
@@ -39,7 +39,7 @@ class ImageEndpoint(
@GetMapping("/plugins/{id}/logo") @GetMapping("/plugins/{id}/logo")
fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? { fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? {
val logo = pluginManagementService.getLogo(pluginId) val logo = pluginService.getLogo(pluginId)
return Utils.inputStreamToResponseEntity(logo) return Utils.inputStreamToResponseEntity(logo)
} }
@@ -4,6 +4,6 @@ interface Configurable {
val configMetadata: List<PluginConfigElement> val configMetadata: List<PluginConfigElement>
var config: Map<String, String?> var config: Map<String, String?>
fun validateConfig(): Boolean = validateConfig(config) fun validateConfig(): PluginConfigValidationResult = validateConfig(config)
fun validateConfig(config: Map<String, String?>): Boolean fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult
} }
@@ -0,0 +1,20 @@
package de.grimsi.gameyfin.pluginapi.core
data class PluginConfigValidationResult(
val result: PluginConfigValidationResultType,
val errors: Map<String, String>? = null
) {
companion object {
val VALID = PluginConfigValidationResult(PluginConfigValidationResultType.VALID)
val UNKNOWN = PluginConfigValidationResult(PluginConfigValidationResultType.UNKNWOWN)
fun INVALID(errors: Map<String, String>): PluginConfigValidationResult {
return PluginConfigValidationResult(PluginConfigValidationResultType.INVALID, errors)
}
}
}
enum class PluginConfigValidationResultType {
VALID,
INVALID,
UNKNWOWN,
}
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.plugins.directdownload
import de.grimsi.gameyfin.pluginapi.core.ConfigurableGameyfinPlugin import de.grimsi.gameyfin.pluginapi.core.ConfigurableGameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.download.Download import de.grimsi.gameyfin.pluginapi.download.Download
import de.grimsi.gameyfin.pluginapi.download.DownloadProvider import de.grimsi.gameyfin.pluginapi.download.DownloadProvider
import de.grimsi.gameyfin.pluginapi.download.FileDownload import de.grimsi.gameyfin.pluginapi.download.FileDownload
@@ -26,21 +27,26 @@ class DirectDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(
override val configMetadata: List<PluginConfigElement> = listOf( override val configMetadata: List<PluginConfigElement> = listOf(
PluginConfigElement( PluginConfigElement(
key = "compressionMode", key = "compressionMode",
name = "Compression mode for generated ZIP files (\"none\", \"fast\", \"best\")", name = "Compression mode for generated ZIP files (\"none\" = default, \"fast\", \"best\")",
description = "Higher compression uses more CPU but saves bandwidth", description = "Higher compression uses more CPU but saves bandwidth",
) )
) )
override fun validateConfig(config: Map<String, String?>): Boolean { override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
return config["compressionMode"]?.let { val compressionMode = config["compressionMode"]
try {
CompressionMode.valueOf(it.uppercase()) if (compressionMode != null) {
true return try {
CompressionMode.valueOf(compressionMode.uppercase())
PluginConfigValidationResult.VALID
} catch (_: IllegalArgumentException) { } catch (_: IllegalArgumentException) {
log.error("Invalid compression mode: $it") PluginConfigValidationResult.INVALID(
false mapOf("compressionMode" to "Invalid compression mode: $compressionMode (must be \"none\", \"fast\", or \"best\")")
)
} }
} ?: true }
return PluginConfigValidationResult.VALID
} }
@Extension @Extension
@@ -1,4 +1,4 @@
Plugin-Version: 1.0.0-alpha1 Plugin-Version: 1.0.0-alpha2
Plugin-Class: de.grimsi.gameyfin.plugins.directdownload.DirectDownloadPlugin Plugin-Class: de.grimsi.gameyfin.plugins.directdownload.DirectDownloadPlugin
Plugin-Id: de.grimsi.gameyfin.directdownload Plugin-Id: de.grimsi.gameyfin.directdownload
Plugin-Name: Direct Download Plugin-Name: Direct Download
@@ -5,10 +5,7 @@ import com.api.igdb.exceptions.RequestException
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.Configurable import de.grimsi.gameyfin.pluginapi.core.*
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.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import me.xdrop.fuzzywuzzy.FuzzySearch import me.xdrop.fuzzywuzzy.FuzzySearch
@@ -36,19 +33,24 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable
) )
override var config: Map<String, String?> = emptyMap() override var config: Map<String, String?> = emptyMap()
override fun validateConfig(config: Map<String, String?>): Boolean { override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
try { try {
authenticate() authenticate(config["clientId"], config["clientSecret"])
return true return PluginConfigValidationResult.VALID
} catch (e: PluginConfigError) { } catch (e: PluginConfigError) {
log.error(e.message) log.error(e.message)
return false return PluginConfigValidationResult.INVALID(
mapOf(
"clientId" to "Invalid client ID and/or client secret",
"clientSecret" to "Invalid client ID and/or client secret"
)
)
} }
} }
override fun start() { override fun start() {
try { try {
authenticate() authenticate(config["clientId"], config["clientSecret"])
} catch (e: PluginConfigError) { } catch (e: PluginConfigError) {
log.error(e.message) log.error(e.message)
} }
@@ -58,11 +60,11 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable
log.debug("IgdbPlugin.stop()") log.debug("IgdbPlugin.stop()")
} }
private fun authenticate() { private fun authenticate(clientId: String? = null, clientSecret: String? = null) {
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 = clientId ?: throw PluginConfigError("Twitch Client ID not set")
val clientSecret: String = config["clientSecret"] ?: throw PluginConfigError("Twitch Client Secret not set") val clientSecret: String = 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 with provided credentials") ?: throw PluginConfigError("Failed to authenticate on Twitch API with provided credentials")
+1 -1
View File
@@ -1,4 +1,4 @@
Plugin-Version: 1.0.0-alpha6 Plugin-Version: 1.0.0-alpha7
Plugin-Class: de.grimsi.gameyfin.plugins.igdb.IgdbPlugin Plugin-Class: de.grimsi.gameyfin.plugins.igdb.IgdbPlugin
Plugin-Id: de.grimsi.gameyfin.igdb Plugin-Id: de.grimsi.gameyfin.igdb
Plugin-Name: IGDB Metadata Plugin-Name: IGDB Metadata
@@ -3,6 +3,7 @@ package de.grimsi.gameyfin.plugins.steamgriddb
import de.grimsi.gameyfin.pluginapi.core.ConfigurableGameyfinPlugin import de.grimsi.gameyfin.pluginapi.core.ConfigurableGameyfinPlugin
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement 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.core.PluginConfigValidationResult
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
import de.grimsi.gameyfin.plugins.steamgriddb.api.SteamGridDbApiClient import de.grimsi.gameyfin.plugins.steamgriddb.api.SteamGridDbApiClient
@@ -28,28 +29,30 @@ class SteamGridDbPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(wra
) )
) )
override fun validateConfig(config: Map<String, String?>): Boolean { override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
try { try {
runBlocking { authenticate() } runBlocking { authenticate(config["apiKey"]) }
return true return PluginConfigValidationResult.VALID
} catch (e: PluginConfigError) { } catch (e: PluginConfigError) {
log.error(e.message) log.error(e.message)
return false return PluginConfigValidationResult.INVALID(
mapOf("apiKey" to "Invalid API key")
)
} }
} }
override fun start() { override fun start() {
try { try {
runBlocking { authenticate() } runBlocking { authenticate(config["apiKey"]) }
} catch (e: PluginConfigError) { } catch (e: PluginConfigError) {
log.error(e.message) log.error(e.message)
} }
} }
private suspend fun authenticate() { private suspend fun authenticate(apiKey: String? = null) {
log.debug("Authenticating on SteamGridDB API...") log.debug("Authenticating on SteamGridDB API...")
val apiKey: String = config["apiKey"] ?: throw PluginConfigError("SteamGridDB API key not set") val apiKey: String = apiKey ?: throw PluginConfigError("SteamGridDB API key not set")
val client = SteamGridDbApiClient(apiKey) val client = SteamGridDbApiClient(apiKey)
if (!client.isApiKeyValid()) { if (!client.isApiKeyValid()) {
@@ -1,4 +1,4 @@
Plugin-Version: 1.0.0-alpha3 Plugin-Version: 1.0.0-alpha4
Plugin-Class: de.grimsi.gameyfin.plugins.steamgriddb.SteamGridDbPlugin Plugin-Class: de.grimsi.gameyfin.plugins.steamgriddb.SteamGridDbPlugin
Plugin-Id: de.grimsi.gameyfin.steamgriddb Plugin-Id: de.grimsi.gameyfin.steamgriddb
Plugin-Name: SteamGridDB Covers Plugin-Name: SteamGridDB Covers