From d9fef0f30c834603bdcaab456a488176b9a05da2 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Mon, 19 May 2025 12:21:42 +0200 Subject: [PATCH] Extend plugin config validation Cache validation results Show field-level errors in UI Enable manual revalidation Move PluginConfigService and PluginManagementService into PluginService --- .run/UI debug.run.xml | 2 +- .../general/cards/PluginManagementCard.tsx | 30 +++---- .../components/general/input/Input.tsx | 6 +- .../general/modals/PluginDetailsModal.tsx | 35 +++++++- .../grimsi/gameyfin/config/ConfigEndpoint.kt | 4 + .../grimsi/gameyfin/config/ConfigService.kt | 9 +- .../gameyfin/core/plugins/PluginEndpoint.kt | 23 ++--- .../plugins/config/PluginConfigService.kt | 22 +---- .../config/PluginConfigValidationResult.kt | 7 -- .../gameyfin/core/plugins/dto/PluginDto.kt | 2 + .../core/plugins/dto/PluginUpdateDto.kt | 2 + .../management/GameyfinPluginManager.kt | 31 +++++-- ...nManagementService.kt => PluginService.kt} | 86 +++++++++++++++---- .../de/grimsi/gameyfin/games/GameService.kt | 8 +- .../de/grimsi/gameyfin/media/ImageEndpoint.kt | 6 +- .../gameyfin/pluginapi/core/Configurable.kt | 4 +- .../core/PluginConfigValidationResult.kt | 20 +++++ .../directdownload/DirectDownloadPlugin.kt | 24 ++++-- .../src/main/resources/MANIFEST.MF | 2 +- .../gameyfin/plugins/igdb/IgdbPlugin.kt | 26 +++--- plugins/igdb/src/main/resources/MANIFEST.MF | 2 +- .../plugins/steamgriddb/SteamGridDbPlugin.kt | 17 ++-- .../src/main/resources/MANIFEST.MF | 2 +- 23 files changed, 240 insertions(+), 130 deletions(-) delete mode 100644 gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/config/PluginConfigValidationResult.kt rename gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/{PluginManagementService.kt => PluginService.kt} (52%) create mode 100644 plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/PluginConfigValidationResult.kt diff --git a/.run/UI debug.run.xml b/.run/UI debug.run.xml index ccfbe55..1a2afd9 100644 --- a/.run/UI debug.run.xml +++ b/.run/UI debug.run.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/gameyfin/src/main/frontend/components/general/cards/PluginManagementCard.tsx b/gameyfin/src/main/frontend/components/general/cards/PluginManagementCard.tsx index 73ea085..d94dad8 100644 --- a/gameyfin/src/main/frontend/components/general/cards/PluginManagementCard.tsx +++ b/gameyfin/src/main/frontend/components/general/cards/PluginManagementCard.tsx @@ -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(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 - case PluginConfigValidationResult.INVALID: + case PluginConfigValidationResultType.INVALID: return @@ -173,10 +166,7 @@ export function PluginManagementCard({plugin}: { plugin: PluginDto }) { {stateToIcon(plugin.state)} - {configValidationResult === undefined ? - : - configValidationResultToChip(configValidationResult) - } + {configValidationResultToChip(plugin.configValidation)} diff --git a/gameyfin/src/main/frontend/components/general/input/Input.tsx b/gameyfin/src/main/frontend/components/general/input/Input.tsx index 55d110e..71a8201 100644 --- a/gameyfin/src/main/frontend/components/general/input/Input.tsx +++ b/gameyfin/src/main/frontend/components/general/input/Input.tsx @@ -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} />
- {meta.touched && meta.error && meta.error.trim().length > 0 && ( + {(meta.touched || showErrorUntouched) && meta.error && meta.error.trim().length > 0 && ( )}
diff --git a/gameyfin/src/main/frontend/components/general/modals/PluginDetailsModal.tsx b/gameyfin/src/main/frontend/components/general/modals/PluginDetailsModal.tsx index 1649b70..0f8de7f 100644 --- a/gameyfin/src/main/frontend/components/general/modals/PluginDetailsModal.tsx +++ b/gameyfin/src/main/frontend/components/general/modals/PluginDetailsModal.tsx @@ -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(false); + async function saveConfig(values: Record) { await PluginEndpoint.updateConfig(plugin.id, values); addToast({ @@ -30,13 +33,14 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi {(onClose) => ( { await saveConfig(values); onClose(); }} > - {(formik: { isSubmitting: any; }) => ( + {(formik: any) => (
Plugin configuration for {plugin.name} @@ -88,10 +92,33 @@ export default function PluginDetailsModal({plugin, isOpen, onOpenChange}: Plugi >{plugin.description} -

Configuration

+
+

Configuration

+
+ {(plugin.configMetadata && plugin.configMetadata.length > 0) && <> + {configValidated && +

Validation successful

} + + + + } +
{(plugin.configMetadata && plugin.configMetadata.length > 0) ? plugin.configMetadata.map((entry: PluginConfigElement) => ( )) : "This plugin has no configuration options." } diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt index 984d092..76d01aa 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigEndpoint.kt @@ -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 **/ diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt index 287223d..1f1f8ba 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt @@ -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() fun subscribe(): Flux { + 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()}]" } + } } /** diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginEndpoint.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginEndpoint.kt index 65439d0..54ceea9 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginEndpoint.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/PluginEndpoint.kt @@ -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 { 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) = - pluginManagementService.setPluginPriorities(pluginPriorities) + pluginService.setPluginPriorities(pluginPriorities) fun validatePluginConfig(pluginId: String): PluginConfigValidationResult = - pluginManagementService.validatePluginConfig(pluginId) + pluginService.validatePluginConfig(pluginId, true) + + fun validateNewConfig(pluginId: String, config: Map): PluginConfigValidationResult = + pluginService.validatePluginConfig(pluginId, config) fun updateConfig(pluginId: String, updatedConfig: Map) = - pluginManagementService.updateConfig(pluginId, updatedConfig) + pluginService.updateConfig(pluginId, updatedConfig) } \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/config/PluginConfigService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/config/PluginConfigService.kt index 1b4205d..8e8c1c1 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/config/PluginConfigService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/config/PluginConfigService.kt @@ -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 { - 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 { - 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) { - 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) - } } \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/config/PluginConfigValidationResult.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/config/PluginConfigValidationResult.kt deleted file mode 100644 index 181b5ee..0000000 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/config/PluginConfigValidationResult.kt +++ /dev/null @@ -1,7 +0,0 @@ -package de.grimsi.gameyfin.core.plugins.config - -enum class PluginConfigValidationResult { - VALID, - INVALID, - UNKNWOWN, -} \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/dto/PluginDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/dto/PluginDto.kt index ea94fa5..1b569ad 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/dto/PluginDto.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/dto/PluginDto.kt @@ -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? = null, val config: Map? = null, + val configValidation: PluginConfigValidationResult? = null, val priority: Int, val trustLevel: PluginTrustLevel, ) \ No newline at end of file diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/dto/PluginUpdateDto.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/dto/PluginUpdateDto.kt index c70805c..e673bc7 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/dto/PluginUpdateDto.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/dto/PluginUpdateDto.kt @@ -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? = null, + val configValidation: PluginConfigValidationResult? = null, val priority: Int? = null ) diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginManager.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginManager.kt index 124ece5..19fc3e3 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginManager.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/GameyfinPluginManager.kt @@ -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): 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> { diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginService.kt similarity index 52% rename from gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementService.kt rename to gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginService.kt index 8fbe8b1..faa8853 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginManagementService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/core/plugins/management/PluginService.kt @@ -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() + private val pluginConfigValidationCache = mutableMapOf() 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 { + 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 { @@ -59,16 +76,6 @@ class PluginManagementService( pluginManager.disablePlugin(pluginId) } - fun validatePluginConfig(pluginId: String): PluginConfigValidationResult { - return pluginManager.validatePluginConfig(pluginId) - } - - fun updateConfig(pluginId: String, updatedConfig: Map) { - pluginConfigService.updateConfig(pluginId, updatedConfig) - val update = PluginUpdateDto(pluginId, config = updatedConfig) - pluginUpdates.tryEmitNext(update) - } - fun setPluginPriorities(pluginPriorities: Map) { pluginPriorities.forEach { (pluginId, priority) -> val pluginManagementEntry = getPluginManagementEntry(pluginId) @@ -82,6 +89,52 @@ class PluginManagementService( return plugin.getLogo() } + fun getConfigMetadata(pluginWrapper: PluginWrapper): List { + 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 { + 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) { + 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): 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 ) diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt index a037834..7dfb530 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/games/GameService.kt @@ -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) -> diff --git a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/media/ImageEndpoint.kt b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/media/ImageEndpoint.kt index d974d05..bcddc39 100644 --- a/gameyfin/src/main/kotlin/de/grimsi/gameyfin/media/ImageEndpoint.kt +++ b/gameyfin/src/main/kotlin/de/grimsi/gameyfin/media/ImageEndpoint.kt @@ -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? { - val logo = pluginManagementService.getLogo(pluginId) + val logo = pluginService.getLogo(pluginId) return Utils.inputStreamToResponseEntity(logo) } diff --git a/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/Configurable.kt b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/Configurable.kt index 47a7b1d..af07549 100644 --- a/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/Configurable.kt +++ b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/Configurable.kt @@ -4,6 +4,6 @@ interface Configurable { val configMetadata: List var config: Map - fun validateConfig(): Boolean = validateConfig(config) - fun validateConfig(config: Map): Boolean + fun validateConfig(): PluginConfigValidationResult = validateConfig(config) + fun validateConfig(config: Map): PluginConfigValidationResult } \ No newline at end of file diff --git a/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/PluginConfigValidationResult.kt b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/PluginConfigValidationResult.kt new file mode 100644 index 0000000..338b5d3 --- /dev/null +++ b/plugin-api/src/main/kotlin/de/grimsi/gameyfin/pluginapi/core/PluginConfigValidationResult.kt @@ -0,0 +1,20 @@ +package de.grimsi.gameyfin.pluginapi.core + +data class PluginConfigValidationResult( + val result: PluginConfigValidationResultType, + val errors: Map? = null +) { + companion object { + val VALID = PluginConfigValidationResult(PluginConfigValidationResultType.VALID) + val UNKNOWN = PluginConfigValidationResult(PluginConfigValidationResultType.UNKNWOWN) + fun INVALID(errors: Map): PluginConfigValidationResult { + return PluginConfigValidationResult(PluginConfigValidationResultType.INVALID, errors) + } + } +} + +enum class PluginConfigValidationResultType { + VALID, + INVALID, + UNKNWOWN, +} \ No newline at end of file diff --git a/plugins/directdownload/src/main/kotlin/de/grimsi/gameyfin/plugins/directdownload/DirectDownloadPlugin.kt b/plugins/directdownload/src/main/kotlin/de/grimsi/gameyfin/plugins/directdownload/DirectDownloadPlugin.kt index 72e275e..a63e471 100644 --- a/plugins/directdownload/src/main/kotlin/de/grimsi/gameyfin/plugins/directdownload/DirectDownloadPlugin.kt +++ b/plugins/directdownload/src/main/kotlin/de/grimsi/gameyfin/plugins/directdownload/DirectDownloadPlugin.kt @@ -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 = 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): Boolean { - return config["compressionMode"]?.let { - try { - CompressionMode.valueOf(it.uppercase()) - true + override fun validateConfig(config: Map): 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 diff --git a/plugins/directdownload/src/main/resources/MANIFEST.MF b/plugins/directdownload/src/main/resources/MANIFEST.MF index 0a124a5..36a2de7 100644 --- a/plugins/directdownload/src/main/resources/MANIFEST.MF +++ b/plugins/directdownload/src/main/resources/MANIFEST.MF @@ -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 diff --git a/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPlugin.kt b/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPlugin.kt index 91463e3..bd587ee 100644 --- a/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPlugin.kt +++ b/plugins/igdb/src/main/kotlin/de/grimsi/gameyfin/plugins/igdb/IgdbPlugin.kt @@ -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 = emptyMap() - override fun validateConfig(config: Map): Boolean { + override fun validateConfig(config: Map): 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") diff --git a/plugins/igdb/src/main/resources/MANIFEST.MF b/plugins/igdb/src/main/resources/MANIFEST.MF index 4679de5..7c28c30 100644 --- a/plugins/igdb/src/main/resources/MANIFEST.MF +++ b/plugins/igdb/src/main/resources/MANIFEST.MF @@ -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 diff --git a/plugins/steamgriddb/src/main/kotlin/de/grimsi/gameyfin/plugins/steamgriddb/SteamGridDbPlugin.kt b/plugins/steamgriddb/src/main/kotlin/de/grimsi/gameyfin/plugins/steamgriddb/SteamGridDbPlugin.kt index 466f9cc..5c8ebf7 100644 --- a/plugins/steamgriddb/src/main/kotlin/de/grimsi/gameyfin/plugins/steamgriddb/SteamGridDbPlugin.kt +++ b/plugins/steamgriddb/src/main/kotlin/de/grimsi/gameyfin/plugins/steamgriddb/SteamGridDbPlugin.kt @@ -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): Boolean { + override fun validateConfig(config: Map): 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()) { diff --git a/plugins/steamgriddb/src/main/resources/MANIFEST.MF b/plugins/steamgriddb/src/main/resources/MANIFEST.MF index 2e2ba07..f10a77a 100644 --- a/plugins/steamgriddb/src/main/resources/MANIFEST.MF +++ b/plugins/steamgriddb/src/main/resources/MANIFEST.MF @@ -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