mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
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:
@@ -1,5 +1,5 @@
|
||||
<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" />
|
||||
</configuration>
|
||||
</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 {
|
||||
CheckCircle,
|
||||
IconContext,
|
||||
@@ -16,31 +16,24 @@ import {
|
||||
XCircle
|
||||
} from "@phosphor-icons/react";
|
||||
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 PluginLogo from "Frontend/components/general/PluginLogo";
|
||||
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 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 }) {
|
||||
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" {
|
||||
if (trustLevel === PluginTrustLevel.UNTRUSTED) return "danger";
|
||||
|
||||
if (isDisabled(state)) return "warning";
|
||||
if (configValidationResult === undefined) return "default";
|
||||
if (!configValidationResult) return "danger";
|
||||
return stateToColor(state);
|
||||
}
|
||||
|
||||
@@ -76,14 +69,14 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
||||
}
|
||||
|
||||
function configValidationResultToChip(validationResult: PluginConfigValidationResult | undefined): ReactNode {
|
||||
switch (validationResult) {
|
||||
case PluginConfigValidationResult.VALID:
|
||||
switch (validationResult?.result) {
|
||||
case PluginConfigValidationResultType.VALID:
|
||||
return <Tooltip content="Config valid" placement="bottom" color="foreground">
|
||||
<Chip size="sm" radius="sm" className="text-xs" color="success">
|
||||
<CheckCircle/>
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
case PluginConfigValidationResult.INVALID:
|
||||
case PluginConfigValidationResultType.INVALID:
|
||||
return <Tooltip content="Config invalid" placement="bottom" color="foreground">
|
||||
<Chip size="sm" radius="sm" className="text-xs" color="danger">
|
||||
<WarningCircle/>
|
||||
@@ -173,10 +166,7 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) {
|
||||
{stateToIcon(plugin.state)}
|
||||
</Tooltip>
|
||||
</Chip>
|
||||
{configValidationResult === undefined ?
|
||||
<Skeleton className="rounded-md h-6 w-9"/> :
|
||||
configValidationResultToChip(configValidationResult)
|
||||
}
|
||||
{configValidationResultToChip(plugin.configValidation)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {SmallInfoField} from "Frontend/components/general/SmallInfoField";
|
||||
import {XCircle} from "@phosphor-icons/react";
|
||||
|
||||
// @ts-ignore
|
||||
const Input = ({label, ...props}) => {
|
||||
const Input = ({label, showErrorUntouched = false, ...props}) => {
|
||||
// @ts-ignore
|
||||
const [field, meta] = useField(props);
|
||||
|
||||
@@ -15,10 +15,10 @@ const Input = ({label, ...props}) => {
|
||||
{...field}
|
||||
id={label}
|
||||
label={label}
|
||||
isInvalid={meta.touched && !!meta.error}
|
||||
isInvalid={(meta.touched || showErrorUntouched) && !!meta.error}
|
||||
/>
|
||||
<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}/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import {addToast, Button, Link, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@heroui/react";
|
||||
import React, {useState} from "react";
|
||||
import {addToast, Button, Link, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Tooltip} from "@heroui/react";
|
||||
import {Form, Formik} from "formik";
|
||||
import PluginConfigElement from "Frontend/generated/de/grimsi/gameyfin/pluginapi/core/PluginConfigElement";
|
||||
import Input from "Frontend/components/general/input/Input";
|
||||
@@ -8,6 +8,7 @@ import Markdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import {PluginEndpoint} from "Frontend/generated/endpoints";
|
||||
import PluginDto from "Frontend/generated/de/grimsi/gameyfin/core/plugins/dto/PluginDto";
|
||||
import {ArrowClockwise} from "@phosphor-icons/react";
|
||||
|
||||
interface PluginDetailsModalProps {
|
||||
plugin: PluginDto;
|
||||
@@ -16,6 +17,8 @@ interface PluginDetailsModalProps {
|
||||
}
|
||||
|
||||
export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: PluginDetailsModalProps) {
|
||||
const [configValidated, setConfigValidated] = useState<boolean>(false);
|
||||
|
||||
async function saveConfig(values: Record<string, string>) {
|
||||
await PluginEndpoint.updateConfig(plugin.id, values);
|
||||
addToast({
|
||||
@@ -30,13 +33,14 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<Formik initialValues={plugin.config}
|
||||
initialErrors={plugin.configValidation?.errors}
|
||||
enableReinitialize={true}
|
||||
onSubmit={async (values: any) => {
|
||||
await saveConfig(values);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{(formik: { isSubmitting: any; }) => (
|
||||
{(formik: any) => (
|
||||
<Form>
|
||||
<ModalHeader className="flex flex-col gap-1">
|
||||
Plugin configuration for {plugin.name}
|
||||
@@ -88,10 +92,33 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi
|
||||
>{plugin.description}</Markdown>
|
||||
</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.map((entry: PluginConfigElement) => (
|
||||
<Input key={entry.key} name={entry.key} label={entry.name}
|
||||
showErrorUntouched={true}
|
||||
type={entry.secret ? "password" : "text"}/>
|
||||
)) : "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.core.Role
|
||||
import de.grimsi.gameyfin.users.util.isAdmin
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.annotation.security.PermitAll
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
@@ -16,6 +17,9 @@ import reactor.core.publisher.Flux
|
||||
class ConfigEndpoint(
|
||||
private val configService: ConfigService
|
||||
) {
|
||||
companion object {
|
||||
val log = KotlinLogging.logger { }
|
||||
}
|
||||
|
||||
/** CRUD endpoints for admins **/
|
||||
|
||||
|
||||
@@ -15,12 +15,19 @@ import java.io.Serializable
|
||||
class ConfigService(
|
||||
private val appConfigRepository: ConfigRepository
|
||||
) {
|
||||
private val log = KotlinLogging.logger {}
|
||||
companion object {
|
||||
private val log = KotlinLogging.logger {}
|
||||
}
|
||||
|
||||
private val configUpdates = Sinks.many().multicast().onBackpressureBuffer<ConfigUpdateDto>()
|
||||
|
||||
fun subscribe(): Flux<ConfigUpdateDto> {
|
||||
log.debug { "New subscription for configUpdates (#${configUpdates.currentSubscriberCount()})" }
|
||||
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 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.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 jakarta.annotation.security.PermitAll
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
@@ -15,28 +15,31 @@ import reactor.core.publisher.Flux
|
||||
@Endpoint
|
||||
@RolesAllowed(Role.Names.ADMIN)
|
||||
class PluginEndpoint(
|
||||
private val pluginManagementService: PluginManagementService
|
||||
private val pluginService: PluginService
|
||||
) {
|
||||
|
||||
@PermitAll
|
||||
fun subscribe(): Flux<PluginUpdateDto> {
|
||||
val user = SecurityContextHolder.getContext().authentication.principal as UserDetails
|
||||
return if (user.isAdmin()) pluginManagementService.subscribe()
|
||||
return if (user.isAdmin()) pluginService.subscribe()
|
||||
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>) =
|
||||
pluginManagementService.setPluginPriorities(pluginPriorities)
|
||||
pluginService.setPluginPriorities(pluginPriorities)
|
||||
|
||||
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>) =
|
||||
pluginManagementService.updateConfig(pluginId, updatedConfig)
|
||||
pluginService.updateConfig(pluginId, updatedConfig)
|
||||
}
|
||||
+2
-20
@@ -1,10 +1,7 @@
|
||||
package de.grimsi.gameyfin.core.plugins.config
|
||||
|
||||
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 org.pf4j.PluginWrapper
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
@@ -13,24 +10,9 @@ class PluginConfigService(
|
||||
private val pluginManager: GameyfinPluginManager
|
||||
) {
|
||||
|
||||
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
|
||||
companion object {
|
||||
private val log = KotlinLogging.logger {}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
-7
@@ -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.pluginapi.core.PluginConfigElement
|
||||
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
|
||||
import org.pf4j.PluginState
|
||||
|
||||
data class PluginDto(
|
||||
@@ -18,6 +19,7 @@ data class PluginDto(
|
||||
val state: PluginState,
|
||||
val configMetadata: List<PluginConfigElement>? = null,
|
||||
val config: Map<String, String?>? = null,
|
||||
val configValidation: PluginConfigValidationResult? = null,
|
||||
val priority: Int,
|
||||
val trustLevel: PluginTrustLevel,
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.grimsi.gameyfin.core.plugins.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude
|
||||
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
|
||||
import org.pf4j.PluginState
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@@ -8,5 +9,6 @@ data class PluginUpdateDto(
|
||||
val id: String,
|
||||
val state: PluginState? = null,
|
||||
val config: Map<String, String?>? = null,
|
||||
val configValidation: PluginConfigValidationResult? = null,
|
||||
val priority: Int? = null
|
||||
)
|
||||
|
||||
+23
-8
@@ -1,8 +1,9 @@
|
||||
package de.grimsi.gameyfin.core.plugins.management
|
||||
|
||||
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.PluginConfigValidationResult
|
||||
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResultType
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.pf4j.*
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
@@ -34,9 +35,9 @@ class GameyfinPluginManager(
|
||||
private val log = KotlinLogging.logger {}
|
||||
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 {
|
||||
// This took me way too long to figure out...
|
||||
// But I learned a lot about Kotlin and Java interoperability in the process
|
||||
pluginStatusProvider = dbPluginStatusProvider
|
||||
|
||||
pluginStateListeners.add { event ->
|
||||
@@ -143,7 +144,7 @@ class GameyfinPluginManager(
|
||||
}
|
||||
|
||||
// Validate config before starting the plugin
|
||||
if (validatePluginConfig(pluginId) == PluginConfigValidationResult.INVALID) {
|
||||
if (validatePluginConfig(pluginId).result == PluginConfigValidationResultType.INVALID) {
|
||||
log.warn { "Plugin $pluginId has invalid configuration" }
|
||||
|
||||
val pluginWrapper = getPlugin(pluginId)
|
||||
@@ -171,14 +172,28 @@ class GameyfinPluginManager(
|
||||
val plugin = try {
|
||||
getPlugin(pluginId)?.plugin
|
||||
} catch (_: NoClassDefFoundError) {
|
||||
return PluginConfigValidationResult.UNKNWOWN
|
||||
return PluginConfigValidationResult(PluginConfigValidationResultType.UNKNWOWN)
|
||||
}
|
||||
|
||||
if (plugin !is Configurable || plugin.validateConfig()) {
|
||||
return PluginConfigValidationResult.VALID
|
||||
if (plugin !is Configurable) {
|
||||
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>> {
|
||||
|
||||
+70
-16
@@ -1,10 +1,16 @@
|
||||
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.PluginConfigValidationResult
|
||||
import de.grimsi.gameyfin.core.plugins.dto.PluginDto
|
||||
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.PluginConfigElement
|
||||
import de.grimsi.gameyfin.pluginapi.core.PluginConfigValidationResult
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.pf4j.ExtensionPoint
|
||||
import org.pf4j.PluginWrapper
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
@@ -13,21 +19,32 @@ import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Sinks
|
||||
|
||||
@Service
|
||||
class PluginManagementService(
|
||||
class PluginService(
|
||||
private val pluginManager: GameyfinPluginManager,
|
||||
private val pluginConfigService: PluginConfigService,
|
||||
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 pluginConfigValidationCache = mutableMapOf<String, PluginConfigValidationResult>()
|
||||
|
||||
init {
|
||||
pluginManager.addPluginStateListener {
|
||||
pluginUpdates.tryEmitNext(PluginUpdateDto(id = it.plugin.pluginId, state = it.pluginState))
|
||||
pluginManager.addPluginStateListener { event ->
|
||||
pluginUpdates.tryEmitNext(PluginUpdateDto(id = event.plugin.pluginId, state = event.pluginState))
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribe(): Flux<PluginUpdateDto> {
|
||||
log.debug { "New subscription for pluginUpdates" }
|
||||
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> {
|
||||
@@ -59,16 +76,6 @@ class PluginManagementService(
|
||||
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>) {
|
||||
pluginPriorities.forEach { (pluginId, priority) ->
|
||||
val pluginManagementEntry = getPluginManagementEntry(pluginId)
|
||||
@@ -82,6 +89,52 @@ class PluginManagementService(
|
||||
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 {
|
||||
val pluginManagementEntry = getPluginManagementEntry(pluginWrapper.pluginId)
|
||||
|
||||
@@ -108,8 +161,9 @@ class PluginManagementService(
|
||||
url = descriptor.pluginUrl,
|
||||
hasLogo = hasLogo,
|
||||
state = pluginWrapper.pluginState,
|
||||
configMetadata = pluginConfigService.getConfigMetadata(pluginWrapper),
|
||||
config = pluginConfigService.getConfig(pluginWrapper),
|
||||
configMetadata = getConfigMetadata(pluginWrapper),
|
||||
config = getConfig(pluginWrapper),
|
||||
configValidation = validatePluginConfig(descriptor.pluginId),
|
||||
priority = pluginManagementEntry.priority,
|
||||
trustLevel = pluginManagementEntry.trustLevel
|
||||
)
|
||||
@@ -3,7 +3,7 @@ package de.grimsi.gameyfin.games
|
||||
import de.grimsi.gameyfin.core.alphaNumeric
|
||||
import de.grimsi.gameyfin.core.filterValuesNotNull
|
||||
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.games.dto.GameDto
|
||||
import de.grimsi.gameyfin.games.dto.GameMetadataDto
|
||||
@@ -28,7 +28,7 @@ import java.nio.file.Path
|
||||
@Service
|
||||
class GameService(
|
||||
private val pluginManager: PluginManager,
|
||||
private val pluginManagementService: PluginManagementService,
|
||||
private val pluginService: PluginService,
|
||||
private val gameRepository: GameRepository,
|
||||
private val companyService: CompanyService
|
||||
) {
|
||||
@@ -179,11 +179,11 @@ class GameService(
|
||||
|
||||
// Cache the plugin management entries for each provider
|
||||
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
|
||||
val sortedResults = results.entries.sortedByDescending {
|
||||
pluginManagementService.getPluginManagementEntry(it.key.javaClass).priority
|
||||
pluginService.getPluginManagementEntry(it.key.javaClass).priority
|
||||
}
|
||||
|
||||
sortedResults.forEach { (provider, metadata) ->
|
||||
|
||||
@@ -3,7 +3,7 @@ package de.grimsi.gameyfin.media
|
||||
import de.grimsi.gameyfin.core.Role
|
||||
import de.grimsi.gameyfin.core.Utils
|
||||
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.ImageType
|
||||
import de.grimsi.gameyfin.users.UserService
|
||||
@@ -24,7 +24,7 @@ import org.springframework.web.multipart.MultipartFile
|
||||
class ImageEndpoint(
|
||||
private val imageService: ImageService,
|
||||
private val userService: UserService,
|
||||
private val pluginManagementService: PluginManagementService
|
||||
private val pluginService: PluginService
|
||||
) {
|
||||
|
||||
@GetMapping("/screenshot/{id}")
|
||||
@@ -39,7 +39,7 @@ class ImageEndpoint(
|
||||
|
||||
@GetMapping("/plugins/{id}/logo")
|
||||
fun getPluginLogo(@PathVariable("id") pluginId: String): ResponseEntity<ByteArrayResource>? {
|
||||
val logo = pluginManagementService.getLogo(pluginId)
|
||||
val logo = pluginService.getLogo(pluginId)
|
||||
return Utils.inputStreamToResponseEntity(logo)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,6 @@ interface Configurable {
|
||||
val configMetadata: List<PluginConfigElement>
|
||||
var config: Map<String, String?>
|
||||
|
||||
fun validateConfig(): Boolean = validateConfig(config)
|
||||
fun validateConfig(config: Map<String, String?>): Boolean
|
||||
fun validateConfig(): PluginConfigValidationResult = validateConfig(config)
|
||||
fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult
|
||||
}
|
||||
+20
@@ -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,
|
||||
}
|
||||
+15
-9
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.plugins.directdownload
|
||||
|
||||
import de.grimsi.gameyfin.pluginapi.core.ConfigurableGameyfinPlugin
|
||||
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.DownloadProvider
|
||||
import de.grimsi.gameyfin.pluginapi.download.FileDownload
|
||||
@@ -26,21 +27,26 @@ class DirectDownloadPlugin(wrapper: PluginWrapper) : ConfigurableGameyfinPlugin(
|
||||
override val configMetadata: List<PluginConfigElement> = listOf(
|
||||
PluginConfigElement(
|
||||
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",
|
||||
)
|
||||
)
|
||||
|
||||
override fun validateConfig(config: Map<String, String?>): Boolean {
|
||||
return config["compressionMode"]?.let {
|
||||
try {
|
||||
CompressionMode.valueOf(it.uppercase())
|
||||
true
|
||||
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
|
||||
val compressionMode = config["compressionMode"]
|
||||
|
||||
if (compressionMode != null) {
|
||||
return try {
|
||||
CompressionMode.valueOf(compressionMode.uppercase())
|
||||
PluginConfigValidationResult.VALID
|
||||
} catch (_: IllegalArgumentException) {
|
||||
log.error("Invalid compression mode: $it")
|
||||
false
|
||||
PluginConfigValidationResult.INVALID(
|
||||
mapOf("compressionMode" to "Invalid compression mode: $compressionMode (must be \"none\", \"fast\", or \"best\")")
|
||||
)
|
||||
}
|
||||
} ?: true
|
||||
}
|
||||
|
||||
return PluginConfigValidationResult.VALID
|
||||
}
|
||||
|
||||
@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-Id: de.grimsi.gameyfin.directdownload
|
||||
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.TwitchAuthenticator
|
||||
import com.api.igdb.request.games
|
||||
import de.grimsi.gameyfin.pluginapi.core.Configurable
|
||||
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.*
|
||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadata
|
||||
import de.grimsi.gameyfin.pluginapi.gamemetadata.GameMetadataProvider
|
||||
import me.xdrop.fuzzywuzzy.FuzzySearch
|
||||
@@ -36,19 +33,24 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable
|
||||
)
|
||||
override var config: Map<String, String?> = emptyMap()
|
||||
|
||||
override fun validateConfig(config: Map<String, String?>): Boolean {
|
||||
override fun validateConfig(config: Map<String, String?>): PluginConfigValidationResult {
|
||||
try {
|
||||
authenticate()
|
||||
return true
|
||||
authenticate(config["clientId"], config["clientSecret"])
|
||||
return PluginConfigValidationResult.VALID
|
||||
} catch (e: PluginConfigError) {
|
||||
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() {
|
||||
try {
|
||||
authenticate()
|
||||
authenticate(config["clientId"], config["clientSecret"])
|
||||
} catch (e: PluginConfigError) {
|
||||
log.error(e.message)
|
||||
}
|
||||
@@ -58,11 +60,11 @@ class IgdbPlugin(wrapper: PluginWrapper) : GameyfinPlugin(wrapper), Configurable
|
||||
log.debug("IgdbPlugin.stop()")
|
||||
}
|
||||
|
||||
private fun authenticate() {
|
||||
private fun authenticate(clientId: String? = null, clientSecret: String? = null) {
|
||||
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 = clientId ?: throw PluginConfigError("Twitch Client ID not set")
|
||||
val clientSecret: String = clientSecret ?: throw PluginConfigError("Twitch Client Secret not set")
|
||||
|
||||
val token = TwitchAuthenticator.requestTwitchToken(clientId, clientSecret)
|
||||
?: throw PluginConfigError("Failed to authenticate on Twitch API with provided credentials")
|
||||
|
||||
@@ -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-Id: de.grimsi.gameyfin.igdb
|
||||
Plugin-Name: IGDB Metadata
|
||||
|
||||
+10
-7
@@ -3,6 +3,7 @@ package de.grimsi.gameyfin.plugins.steamgriddb
|
||||
import de.grimsi.gameyfin.pluginapi.core.ConfigurableGameyfinPlugin
|
||||
import de.grimsi.gameyfin.pluginapi.core.PluginConfigElement
|
||||
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.GameMetadataProvider
|
||||
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 {
|
||||
runBlocking { authenticate() }
|
||||
return true
|
||||
runBlocking { authenticate(config["apiKey"]) }
|
||||
return PluginConfigValidationResult.VALID
|
||||
} catch (e: PluginConfigError) {
|
||||
log.error(e.message)
|
||||
return false
|
||||
return PluginConfigValidationResult.INVALID(
|
||||
mapOf("apiKey" to "Invalid API key")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
try {
|
||||
runBlocking { authenticate() }
|
||||
runBlocking { authenticate(config["apiKey"]) }
|
||||
} catch (e: PluginConfigError) {
|
||||
log.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun authenticate() {
|
||||
private suspend fun authenticate(apiKey: String? = null) {
|
||||
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)
|
||||
|
||||
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-Id: de.grimsi.gameyfin.steamgriddb
|
||||
Plugin-Name: SteamGridDB Covers
|
||||
|
||||
Reference in New Issue
Block a user